Merge branch 'main' into feat/list-tree-collapse
This commit is contained in:
commit
2745487987
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 === '') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
@ -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='
|
||||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
: ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": "δευτερόλεπτο|δευτερόλεπτα",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": "секунда|секунд(и)",
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
|
|||
projectId: number
|
||||
duplicatedProject: IProject | null
|
||||
parentProjectId: IProject['id']
|
||||
duplicateShares: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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} }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] "
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//////
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬ifications.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
|
||||
}
|
||||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue