diff --git a/.gitignore b/.gitignore index 25d23cac0..469ce5eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ docs/resources/ pkg/static/templates_vfsdata.go files/ !pkg/files/ +!pkg/web/files/ vikunja-dump* vendor/ os-packages/ diff --git a/.golangci.yml b/.golangci.yml index 6f1a759f2..19ee2f531 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,13 @@ linters: - revive path: pkg/utils/* text: 'var-naming: avoid meaningless package names' + - linters: + - revive + path: pkg/routes/api/shared/* + text: 'var-naming: avoid meaningless package names' + - linters: + - contextcheck + path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' diff --git a/CRUSH.md b/CRUSH.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CRUSH.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/config-raw.json b/config-raw.json index dd395b768..641285994 100644 --- a/config-raw.json +++ b/config-raw.json @@ -997,6 +997,37 @@ } ] }, + { + "key": "audit", + "comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.", + "children": [ + { + "key": "enabled", + "default_value": "false", + "comment": "Whether to enable audit logging." + }, + { + "key": "logfile", + "default_value": "", + "comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path." + }, + { + "key": "rotation", + "children": [ + { + "key": "maxsizemb", + "default_value": "100", + "comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation." + }, + { + "key": "maxage", + "default_value": "30", + "comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever." + } + ] + } + ] + }, { "key": "outgoingrequests", "children": [ diff --git a/desktop/package.json b/desktop/package.json index 9dc1e98e0..687cc9046 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -61,9 +61,9 @@ } }, "devDependencies": { - "electron": "40.10.2", - "electron-builder": "26.15.0", - "unzipper": "0.12.3" + "electron": "40.10.5", + "electron-builder": "26.15.3", + "unzipper": "0.12.5" }, "dependencies": { "express": "5.2.1" @@ -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" } } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 6481b9c32..443354231 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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,14 +23,14 @@ importers: version: 5.2.1 devDependencies: electron: - specifier: 40.10.2 - version: 40.10.2 + specifier: 40.10.5 + version: 40.10.5 electron-builder: - specifier: 26.15.0 - version: 26.15.0(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 + specifier: 0.12.5 + version: 0.12.5 packages: @@ -39,6 +41,10 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@electron-internal/extract-zip@1.0.2': + resolution: {integrity: sha512-VJuNETNPEhrmQEZezeTZO5TZMV+dobBRyJ7zHjGJWIhMS7m7W1UeClt69u4hkUxv9ZZVxuli/E9Yvc4gDNHGsg==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -48,14 +54,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.2.1': resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==} engines: {node: '>= 10.0.0'} @@ -74,8 +80,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.3': - resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -115,14 +121,6 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@peculiar/asn1-schema@2.7.0': resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} @@ -177,9 +175,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -188,9 +183,9 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -241,12 +236,12 @@ packages: dmg-builder: 24.13.3 electron-builder-squirrel-windows: 24.13.3 - app-builder-lib@26.15.0: - resolution: {integrity: sha512-j2+P6Lh+l/VuWfXZWSs7u+OAPqYJQGnZZO30M833XQQaRuyohm4RZk7Gw4nQXfeyQH9GqXaTwR16Y0LaVTlS+g==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.15.0 - electron-builder-squirrel-windows: 26.15.0 + 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==} @@ -336,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: @@ -348,10 +343,6 @@ packages: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -387,14 +378,6 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -402,10 +385,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -477,9 +456,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -500,10 +476,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} - engines: {node: '>=8'} - detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -513,8 +485,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.15.0: - resolution: {integrity: sha512-oS8MWttbpIUF/2v8LOEY+f4ayL84ipMOarZvdRMl/pxlhLxAYjYMklTXHEXIl37Ig+qJv/bVF7HgyIoOoZyMWA==} + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dotenv-expand@11.0.6: resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==} @@ -552,20 +524,20 @@ packages: electron-builder-squirrel-windows@24.13.3: resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==} - electron-builder@26.15.0: - resolution: {integrity: sha512-zd4cfvjHmtyGqMaDudg5rAjNUkwIJDz8ICaCsz77hFKcjMQHcZNNNCs/C4phwN9+gEVwmhvpKMzNFum6fs/n6A==} + 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.0: - resolution: {integrity: sha512-pt6K3ol/a+o3HbqmYkL2NYlVH5pd34tL4FPRcgX8E88xQAqQyIsseXe4vWy7Pq2BaYy+iFGJrtInZe11FFAQwQ==} + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron@40.10.2: - resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==} - engines: {node: '>= 12.20.55'} + electron@40.10.5: + resolution: {integrity: sha512-VzTIvwOYXZZufT9B83GDQogR1TFqREygRYhm0LE++QhGPjvBeg+W7siOP9K5+9rHMUnRuCX4YU/0ivLekN/UZQ==} + engines: {node: '>= 22.12.0'} hasBin: true emoji-regex@8.0.0: @@ -578,9 +550,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -588,6 +557,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -632,11 +605,6 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -646,9 +614,6 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -669,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: @@ -688,10 +653,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} - engines: {node: '>=14.14'} - fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} @@ -704,10 +665,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -777,6 +734,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'} @@ -808,10 +769,6 @@ packages: resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} engines: {node: '>= 14'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -819,10 +776,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -830,10 +783,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -846,17 +795,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -875,6 +816,10 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -887,8 +832,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: @@ -911,9 +856,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -945,10 +887,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -960,10 +898,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} - matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -1001,10 +935,6 @@ packages: engines: {node: '>=4.0.0'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -1020,30 +950,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1066,17 +972,17 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-path@3.0.0: @@ -1102,14 +1008,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -1118,10 +1016,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1148,9 +1042,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -1167,9 +1058,9 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1255,10 +1146,6 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -1358,18 +1245,6 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -1380,10 +1255,6 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -1426,8 +1297,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: @@ -1443,8 +1314,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: @@ -1473,13 +1344,13 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} + undici@6.26.0: + resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + engines: {node: '>=18.17'} - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -1493,8 +1364,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unzipper@0.12.3: - resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1509,9 +1380,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webcrypto-core@1.9.2: resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} @@ -1525,6 +1393,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1559,9 +1432,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1579,6 +1449,8 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@electron-internal/extract-zip@1.0.2': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -1591,7 +1463,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -1605,17 +1477,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.1 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.27.2 transitivePeerDependencies: - supports-color @@ -1657,21 +1528,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.3': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.0.3 - got: 11.8.6 - graceful-fs: 4.2.11 node-abi: 4.24.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.4.0 read-binary-file-arch: 1.0.6 - semver: 7.8.1 - tar: 7.5.15 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -1733,20 +1597,6 @@ snapshots: '@noble/hashes@2.2.0': {} - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.8.1 - '@peculiar/asn1-schema@2.7.0': dependencies: '@peculiar/utils': 2.0.3 @@ -1811,16 +1661,11 @@ snapshots: dependencies: '@types/node': 24.10.9 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 24.10.9 - optional: true - '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} accepts@2.0.0: dependencies: @@ -1865,7 +1710,7 @@ snapshots: app-builder-bin@4.0.0: {} - app-builder-lib@24.13.3(dmg-builder@26.15.0)(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 @@ -1879,34 +1724,34 @@ snapshots: builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 debug: 4.4.3 - dmg-builder: 26.15.0(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.0) + 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.0(dmg-builder@26.15.0)(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 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.3 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@noble/hashes': 2.2.0 @@ -1915,22 +1760,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.0(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.0) - electron-publish: 26.15.0 + 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 @@ -1939,10 +1784,10 @@ 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 + unzipper: 0.12.5 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -2077,14 +1922,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 @@ -2094,7 +1939,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 @@ -2107,21 +1952,6 @@ snapshots: bytestreamjs@2.0.1: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 10.4.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.15 - unique-filename: 4.0.0 - cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -2157,12 +1987,6 @@ snapshots: ci-info@4.3.1: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2173,8 +1997,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2232,10 +2054,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -2256,8 +2074,6 @@ snapshots: depd@2.0.0: {} - detect-libc@2.0.3: {} - detect-node@2.1.0: optional: true @@ -2271,12 +2087,12 @@ snapshots: minimatch: 10.2.5 p-limit: 3.1.0 - dmg-builder@26.15.0(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.0(dmg-builder@26.15.0)(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 @@ -2309,9 +2125,9 @@ snapshots: dependencies: jake: 10.8.7 - electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.0): + electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.3): dependencies: - app-builder-lib: 24.13.3(dmg-builder@26.15.0)(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 @@ -2319,14 +2135,14 @@ snapshots: - dmg-builder - supports-color - electron-builder@26.15.0(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.0(dmg-builder@26.15.0)(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.0(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 @@ -2347,25 +2163,25 @@ snapshots: transitivePeerDependencies: - supports-color - electron-publish@26.15.0: + 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.2: + electron@40.10.5: dependencies: - '@electron/get': 2.0.3 + '@electron-internal/extract-zip': 1.0.2 + '@electron/get': 5.0.0 '@types/node': 24.10.9 - extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -2375,17 +2191,14 @@ snapshots: encodeurl@2.0.0: {} - encoding@0.1.13: - dependencies: - iconv-lite: 0.6.3 - optional: true - end-of-stream@1.4.5: dependencies: once: 1.4.0 env-paths@2.2.1: {} + env-paths@3.0.0: {} + err-code@2.0.3: {} es-define-property@1.0.1: {} @@ -2401,7 +2214,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 @@ -2450,26 +2263,12 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-uri@3.1.2: {} - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2494,12 +2293,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: {} @@ -2514,12 +2313,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-extra@11.2.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 @@ -2539,10 +2332,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -2640,6 +2429,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 @@ -2688,19 +2481,12 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - optional: true - iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 ieee754@1.2.1: {} - imurmurhash@0.1.4: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -2708,8 +2494,6 @@ snapshots: inherits@2.0.4: {} - ip-address@10.2.0: {} - ipaddr.js@1.9.1: {} is-ci@3.0.1: @@ -2718,12 +2502,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-interactive@1.0.0: {} - is-promise@4.0.0: {} - is-unicode-supported@0.1.0: {} - isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -2734,6 +2514,8 @@ snapshots: isexe@3.1.1: {} + isexe@4.0.0: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -2749,7 +2531,7 @@ snapshots: jiti@2.6.1: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -2768,12 +2550,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonfile@6.1.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -2802,11 +2578,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} @@ -2815,22 +2586,6 @@ snapshots: dependencies: yallist: 4.0.0 - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color - matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -2856,8 +2611,6 @@ snapshots: mime@2.6.0: {} - mimic-fn@2.1.0: {} - mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -2868,34 +2621,6 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.3 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.5: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@7.1.3: {} minizlib@3.1.0: @@ -2914,26 +2639,24 @@ snapshots: dependencies: semver: 7.8.1 - node-gyp@11.5.0: + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 + 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 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + undici: 6.26.0 + which: 6.0.1 node-int64@0.4.0: {} - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-path@3.0.0: {} @@ -2952,30 +2675,12 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - p-cancelable@2.1.1: {} p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-map@7.0.4: {} - package-json-from-dist@1.0.1: {} parseurl@1.3.3: {} @@ -2993,8 +2698,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - picomatch@4.0.4: {} pkijs@3.4.0: @@ -3018,7 +2721,7 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - proc-log@5.0.0: {} + proc-log@6.1.0: {} process-nextick-args@2.0.1: {} @@ -3079,7 +2782,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 @@ -3117,11 +2820,6 @@ snapshots: dependencies: lowercase-keys: 2.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - retry@0.12.0: {} roarr@2.15.4: @@ -3241,21 +2939,6 @@ snapshots: dependencies: semver: 7.8.1 - smart-buffer@4.2.0: {} - - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -3266,10 +2949,6 @@ snapshots: sprintf-js@1.1.3: optional: true - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - stat-mode@1.0.0: {} statuses@2.0.2: {} @@ -3320,7 +2999,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 @@ -3344,9 +3023,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: {} @@ -3369,13 +3048,10 @@ snapshots: undici-types@7.16.0: {} - unique-filename@4.0.0: - dependencies: - unique-slug: 5.0.0 + undici@6.26.0: {} - unique-slug@5.0.0: - dependencies: - imurmurhash: 0.1.4 + undici@7.27.2: + optional: true universalify@0.1.2: {} @@ -3383,11 +3059,11 @@ snapshots: unpipe@1.0.0: {} - unzipper@0.12.3: + unzipper@0.12.5: dependencies: bluebird: 3.7.2 duplexer2: 0.1.4 - fs-extra: 11.2.0 + fs-extra: 11.3.1 graceful-fs: 4.2.11 node-int64: 0.4.0 @@ -3401,10 +3077,6 @@ snapshots: vary@1.1.2: {} - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - webcrypto-core@1.9.2: dependencies: '@peculiar/asn1-schema': 2.7.0 @@ -3421,6 +3093,10 @@ snapshots: dependencies: isexe: 3.1.1 + which@6.0.1: + dependencies: + isexe: 4.0.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3455,11 +3131,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} zip-stream@4.1.1: diff --git a/devenv.lock b/devenv.lock index 6184f1dc8..6ed2e5f71 100644 --- a/devenv.lock +++ b/devenv.lock @@ -16,62 +16,6 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "owner": "NixOS", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1772893680, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762808025, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "inputs": { "nixpkgs-src": "nixpkgs-src" @@ -125,15 +69,11 @@ "root": { "inputs": { "devenv": "devenv", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-unstable": "nixpkgs-unstable", - "pre-commit-hooks": [ - "git-hooks" - ] + "nixpkgs-unstable": "nixpkgs-unstable" } } }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7a4016cf1..8219d06a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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,41 +105,41 @@ "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.1", + "@types/node": "24.13.2", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", "@vitejs/plugin-vue": "6.0.7", - "@vue/eslint-config-typescript": "14.8.0", + "@vue/eslint-config-typescript": "14.9.0", "@vue/test-utils": "2.4.11", "@vue/tsconfig": "0.9.1", "@vueuse/shared": "14.3.0", - "autoprefixer": "10.5.0", - "browserslist": "4.28.2", - "caniuse-lite": "1.0.30001797", + "autoprefixer": "10.5.1", + "browserslist": "4.28.4", + "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.3", + "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" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8f92ad279..b9f6c6ab4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,13 +6,19 @@ settings: overrides: minimatch: ^10.2.3 - rollup: 4.61.1 + rollup: 4.62.2 basic-ftp: '>=5.2.2' serialize-javascript: ^7.0.5 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' importers: @@ -35,7 +41,7 @@ importers: version: 3.1.3(@fortawesome/fontawesome-svg-core@7.1.0)(vue@3.5.27(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: 11.0.3 - version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.61.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.2)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) '@kyvg/vue3-notification': specifier: 3.4.2 version: 3.4.2(vue@3.5.27(typescript@5.9.3)) @@ -112,8 +118,8 @@ importers: specifier: 1.11.19 version: 1.11.19 dompurify: - specifier: 3.4.0 - version: 3.4.0 + specifier: 3.4.11 + version: 3.4.11 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -176,14 +182,14 @@ importers: version: 4.1.3(vue@3.5.27(typescript@5.9.3)) devDependencies: '@faker-js/faker': - specifier: 10.4.0 - version: 10.4.0 + specifier: 10.5.0 + version: 10.5.0 '@histoire/plugin-screenshot': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) '@histoire/plugin-vue': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -191,8 +197,8 @@ importers: specifier: 3.6.1 version: 3.6.1 '@tailwindcss/vite': - specifier: 4.3.0 - version: 4.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + specifier: 4.3.1 + version: 4.3.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@tsconfig/node24': specifier: 24.0.4 version: 24.0.4 @@ -203,8 +209,8 @@ importers: specifier: 1.0.3 version: 1.0.3 '@types/node': - specifier: 24.13.1 - version: 24.13.1 + specifier: 24.13.2 + version: 24.13.2 '@types/sortablejs': specifier: 1.15.9 version: 1.15.9 @@ -212,17 +218,17 @@ importers: specifier: 8.18.1 version: 8.18.1 '@typescript-eslint/eslint-plugin': - specifier: 8.60.1 - version: 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.62.0 + version: 8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.60.1 - version: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.62.0 + version: 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.7 - version: 6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.7(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': - specifier: 14.8.0 - version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 14.9.0 + version: 14.9.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.11 version: 2.4.11(@vue/compiler-dom@3.5.27)(@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) @@ -233,20 +239,20 @@ importers: specifier: 14.3.0 version: 14.3.0(vue@3.5.27(typescript@5.9.3)) autoprefixer: - specifier: 10.5.0 - version: 10.5.0(postcss@8.5.14) + specifier: 10.5.1 + version: 10.5.1(postcss@8.5.14) browserslist: - specifier: 4.28.2 - version: 4.28.2 + specifier: 4.28.4 + version: 4.28.4 caniuse-lite: - specifier: 1.0.30001797 - version: 1.0.30001797 + specifier: 1.0.30001799 + version: 1.0.30001799 csstype: specifier: 3.2.3 version: 3.2.3 esbuild: - specifier: 0.28.0 - version: 0.28.0 + specifier: '>=0.28.1' + version: 0.28.1 eslint: specifier: 9.39.4 version: 9.39.4(jiti@2.6.1) @@ -255,13 +261,13 @@ importers: version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.9.2 - version: 10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + version: 10.9.2(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: - specifier: 20.10.2 - version: 20.10.2 + specifier: 20.10.6 + version: 20.10.6 histoire: specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + version: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) otplib: specifier: 12.0.1 version: 12.0.1 @@ -275,14 +281,14 @@ importers: specifier: 1.8.1 version: 1.8.1 postcss-preset-env: - specifier: 11.3.0 - version: 11.3.0(postcss@8.5.14) + specifier: 11.3.1 + version: 11.3.1(postcss@8.5.14) rollup: - specifier: 4.61.1 - version: 4.61.1 + specifier: 4.62.2 + version: 4.62.2 rollup-plugin-visualizer: specifier: 6.0.11 - version: 6.0.11(rollup@4.61.1) + version: 6.0.11(rollup@4.62.2) sass-embedded: specifier: 1.100.0 version: 1.100.0 @@ -302,8 +308,8 @@ importers: specifier: 2.1.3 version: 2.1.3(stylelint@17.13.0(typescript@5.9.3)) tailwindcss: - specifier: 4.3.0 - version: 4.3.0 + specifier: 4.3.1 + version: 4.3.1 typescript: specifier: 5.9.3 version: 5.9.3 @@ -312,22 +318,22 @@ importers: version: 3.0.0 vite: specifier: 7.3.5 - version: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + version: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vite-plugin-pwa: specifier: 1.3.0 - version: 1.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) vite-plugin-vue-devtools: - specifier: 8.1.2 - version: 8.1.2(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + specifier: 8.1.3 + version: 8.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) vite-svg-loader: specifier: 5.1.1 version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.13.1)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + specifier: 4.1.9 + version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: - specifier: 3.3.3 - version: 3.3.3(typescript@5.9.3) + specifier: 3.3.5 + version: 3.3.5(typescript@5.9.3) wait-on: specifier: 9.0.10 version: 9.0.10 @@ -347,10 +353,6 @@ packages: resolution: {integrity: sha512-nznEC1ZA/m3hQDEnrGQ4c5gkaa9pcaVnw4LFJyzBAaR7E3nfiAPEHS3otnSafpZouVnoKeITl5D+2LsnwlnK8g==} engines: {node: '>=14.0.0'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -366,28 +368,28 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.26.0': resolution: {integrity: sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==} engines: {node: '>=6.9.0'} - '@babel/core@7.26.0': - resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/generator@7.26.0': - resolution: {integrity: sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.25.9': @@ -402,25 +404,29 @@ packages: resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.25.9': resolution: {integrity: sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-create-regexp-features-plugin@7.25.9': resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-define-polyfill-provider@0.6.2': resolution: {integrity: sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} '@babel/helper-member-expression-to-functions@7.25.9': @@ -431,21 +437,15 @@ packages: resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.26.0': - resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-optimise-call-expression@7.25.9': resolution: {integrity: sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==} @@ -463,13 +463,13 @@ packages: resolution: {integrity: sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-replace-supers@7.25.9': resolution: {integrity: sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-simple-access@7.25.9': resolution: {integrity: sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==} @@ -483,20 +483,32 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.25.9': resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.25.9': resolution: {integrity: sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.26.10': - resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.28.5': @@ -504,8 +516,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true @@ -513,428 +525,420 @@ packages: resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9': resolution: {integrity: sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9': resolution: {integrity: sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9': resolution: {integrity: sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.13.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9': resolution: {integrity: sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-proposal-decorators@7.25.9': resolution: {integrity: sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-decorators@7.25.9': resolution: {integrity: sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-assertions@7.26.0': resolution: {integrity: sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-attributes@7.26.0': resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-meta@7.10.4': resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-jsx@7.25.9': resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-typescript@7.25.9': resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-arrow-functions@7.25.9': resolution: {integrity: sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-async-generator-functions@7.25.9': resolution: {integrity: sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-async-to-generator@7.25.9': resolution: {integrity: sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-block-scoped-functions@7.25.9': resolution: {integrity: sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-block-scoping@7.25.9': resolution: {integrity: sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-class-properties@7.25.9': resolution: {integrity: sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-class-static-block@7.26.0': resolution: {integrity: sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.12.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-classes@7.25.9': resolution: {integrity: sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-computed-properties@7.25.9': resolution: {integrity: sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-destructuring@7.25.9': resolution: {integrity: sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-dotall-regex@7.25.9': resolution: {integrity: sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-duplicate-keys@7.25.9': resolution: {integrity: sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9': resolution: {integrity: sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-dynamic-import@7.25.9': resolution: {integrity: sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-exponentiation-operator@7.25.9': resolution: {integrity: sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-export-namespace-from@7.25.9': resolution: {integrity: sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-for-of@7.25.9': resolution: {integrity: sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-function-name@7.25.9': resolution: {integrity: sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-json-strings@7.25.9': resolution: {integrity: sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-literals@7.25.9': resolution: {integrity: sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-logical-assignment-operators@7.25.9': resolution: {integrity: sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-member-expression-literals@7.25.9': resolution: {integrity: sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-amd@7.25.9': resolution: {integrity: sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-commonjs@7.25.9': resolution: {integrity: sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-systemjs@7.29.4': resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-umd@7.25.9': resolution: {integrity: sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-named-capturing-groups-regex@7.25.9': resolution: {integrity: sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-new-target@7.25.9': resolution: {integrity: sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-nullish-coalescing-operator@7.25.9': resolution: {integrity: sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-numeric-separator@7.25.9': resolution: {integrity: sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-object-rest-spread@7.25.9': resolution: {integrity: sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-object-super@7.25.9': resolution: {integrity: sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-optional-catch-binding@7.25.9': resolution: {integrity: sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-optional-chaining@7.25.9': resolution: {integrity: sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-parameters@7.25.9': resolution: {integrity: sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-private-methods@7.25.9': resolution: {integrity: sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-private-property-in-object@7.25.9': resolution: {integrity: sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-property-literals@7.25.9': resolution: {integrity: sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-regenerator@7.25.9': resolution: {integrity: sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-regexp-modifiers@7.26.0': resolution: {integrity: sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-reserved-words@7.25.9': resolution: {integrity: sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-shorthand-properties@7.25.9': resolution: {integrity: sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-spread@7.25.9': resolution: {integrity: sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-sticky-regex@7.25.9': resolution: {integrity: sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-template-literals@7.25.9': resolution: {integrity: sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typeof-symbol@7.25.9': resolution: {integrity: sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typescript@7.25.9': resolution: {integrity: sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-escapes@7.25.9': resolution: {integrity: sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-property-regex@7.25.9': resolution: {integrity: sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-regex@7.25.9': resolution: {integrity: sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-sets-regex@7.25.9': resolution: {integrity: sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/preset-env@7.26.0': resolution: {integrity: sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/preset-modules@0.1.6-no-external-plugins': resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' '@babel/runtime@7.25.4': resolution: {integrity: sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==} engines: {node: '>=6.9.0'} - '@babel/template@7.26.9': - resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.25.9': - resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} '@bufbuild/protobuf@2.5.2': @@ -1003,8 +1007,8 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-color-parser@4.1.1': - resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -1053,8 +1057,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/postcss-alpha-function@2.0.5': - resolution: {integrity: sha512-i2lNJ6b4GdMoybHlpUM07TIk8KQRXTTe7Qf8LfctQhjDRTIgaodWTQqzWU4fpWO/nxBWNkSloDM22Lw/30NBcg==} + '@csstools/postcss-alpha-function@2.0.6': + resolution: {integrity: sha512-XaMnJJqqZv4veulLELvM+5caEMcLTsFyqTrkwGKPMF+UbiM7dlQoe4K46EnwfSJIvnm91K1ZXsZSd3OuJ04p9w==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1065,26 +1069,26 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-function-display-p3-linear@2.0.4': - resolution: {integrity: sha512-xrGqSFj9pu6XbJYD4NNCxYK9WFbf0KMfXFaisnJezkIRDZCwefUB2azkU4Zr0dFmLtIb9LlshrSZ0be1/QVthQ==} + '@csstools/postcss-color-function-display-p3-linear@2.0.5': + resolution: {integrity: sha512-YzY5qI0S/CsvqvMSiDn85ZyTCRLdnywxQn+6Fv8AU17aCE/fjcor54OSdVb/HlABBTcBq+d8NlWcLz11Bmo2mQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-function@5.0.4': - resolution: {integrity: sha512-PhUu86ppxKcNHHqrJ43ZL1mYa2uHKGRoY0KPbZA9k8iOaanL3I+1zYqbgVumxj1UgNTDw5BE3BUQ1Dono6bD6g==} + '@csstools/postcss-color-function@5.0.5': + resolution: {integrity: sha512-s+9fU1+sZazUNk0WyKShlfmTLC0fosxNY5x7DiD637xXbZLX2lyce23QrdRhytP3Ja1G77qUk6cRD37N1gemdQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-mix-function@4.0.4': - resolution: {integrity: sha512-zYS78MHBuih9f9qtPFcSvVXMKg9q/lNPeFJUjyw7+/W1VHRjubvs5MlzuC363UUeahAhrOvYdo2ZZhmlxZbj6w==} + '@csstools/postcss-color-mix-function@4.0.5': + resolution: {integrity: sha512-eBrrzTKudOlDl2XOJzW/pzHPIkC8tGkcGpNiFO/vmevb08U1huYEINhlxr8iz4OzSqs1GtiJx4d2v5iHFOZjNw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4': - resolution: {integrity: sha512-qlrABMEFPUqbCxX0aOsHcxQZo/8XgMqnEtqqtVUbdizcuTUtJyLdHike7hkoemwDspMSEotdIfRlUY4jhZaD+A==} + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.5': + resolution: {integrity: sha512-O4tE1hZXfEAbTP1IC2R857KjPCLNtpsFUqY2dqgycF/3M6GuFyJI20EWwkxVZzlSFvWdIcNppwRf9pxPFn0qnA==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1101,8 +1105,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-contrast-color-function@3.0.4': - resolution: {integrity: sha512-EiTZzUICztGqEuYg8AVCUWH9vH2jDzO6RryxMja+PWluZHP6n3/iG6i1leTt5LiDQjDUQlCRbQtMNj7V7S+b4Q==} + '@csstools/postcss-contrast-color-function@3.0.5': + resolution: {integrity: sha512-gfdTZ4a5ioL2zM/yN2FqExy6rql+6egkI5sDuK9MvrbfrVJMzB0OjiCkboT5UprU/P0JwfTiIutW1ZSyqK4Icw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1125,20 +1129,20 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-gamut-mapping@3.0.4': - resolution: {integrity: sha512-2dWGsxtxypKU9Ra862F2335W8xegRwl9ohQ6hk808PiQlEahSaFtt5fqsGmKDaSiaFUx+2X8GZxVo970Ajr2vQ==} + '@csstools/postcss-gamut-mapping@3.0.5': + resolution: {integrity: sha512-X6XkKkR9R8KyJey9n1ryEzzfX6WpihPz/JBsyIVvxAlztQcMjMA7I9mMybWVv3ZyRMC+0+H7RlIUe85vZkasNQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-gradients-interpolation-method@6.0.4': - resolution: {integrity: sha512-sC/7dqVTtQTniLjPp/NagzeUn4sGinnMTicNBLDzirKq/GNXuJaApBOnvBmgNXjV6XPizfMhNRYCk5stn3q2nQ==} + '@csstools/postcss-gradients-interpolation-method@6.0.5': + resolution: {integrity: sha512-wXiZI6bLRAGcw7XuzsqqPnTVNrHFkHTkcymK2su+ynJjemfCdpCD9HdG+ICikPqtQ782r6LSZdyC3cDhSQqF3Q==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-hwb-function@5.0.4': - resolution: {integrity: sha512-cl0KPaaeYyAXNHO3pqK8adbpbAGmIU1cT1thyaEkmP8yvbJvmyztkpdGADGqziUUoh4dZQ0IhHxOxnKQ296T+A==} + '@csstools/postcss-hwb-function@5.0.5': + resolution: {integrity: sha512-HeJOXAMr1nYHZ7gJT1+6d899X9Y+5qJcpbLJ8WzhujQOIB4oqbzeP3769sd1xl3eH4qbasxtewxr4crs08SEQw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1233,8 +1237,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-oklab-function@5.0.4': - resolution: {integrity: sha512-vIgrKe5ffW99it5SUIXOBczGLSiTaHBhU6afVr9KPwoZ4uq9H0E3Ehvi+xsUjmvnAyMTxOUSszNo04kEhbvYjQ==} + '@csstools/postcss-oklab-function@5.0.5': + resolution: {integrity: sha512-A+Nkzj2ODvQboM5FlqEcp0iqilyVo78f9FMx/3cHrRrEBqCymSXvf8sa1cTY54lJoUVI3Sn9XysgvYaVIAuIYg==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1263,8 +1267,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-relative-color-syntax@4.0.4': - resolution: {integrity: sha512-reFFKD9eS602We8621e5cAroKD7hH4104duLNBBhzwawGN7dhbnL1+c/DRHqwyq6eGK35HaKMMiifEZhAztlOA==} + '@csstools/postcss-relative-color-syntax@4.0.5': + resolution: {integrity: sha512-kBzf+LIm824cpjsZPhNtl/2N1KK+TXnxy8Kce4y+pEAQSrxhpX6WDUg54wjdHBGx2UZUXKBnlaUOsc71sSRDvg==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1335,470 +1339,158 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.5': - resolution: {integrity: sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.5': - resolution: {integrity: sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.5': - resolution: {integrity: sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.5': - resolution: {integrity: sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.5': - resolution: {integrity: sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.5': - resolution: {integrity: sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.5': - resolution: {integrity: sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.5': - resolution: {integrity: sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.5': - resolution: {integrity: sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.5': - resolution: {integrity: sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.5': - resolution: {integrity: sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.5': - resolution: {integrity: sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.5': - resolution: {integrity: sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.5': - resolution: {integrity: sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.5': - resolution: {integrity: sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.5': - resolution: {integrity: sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.5': - resolution: {integrity: sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.5': - resolution: {integrity: sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.5': - resolution: {integrity: sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.5': - resolution: {integrity: sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.5': - resolution: {integrity: sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.5': - resolution: {integrity: sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.5': - resolution: {integrity: sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.5': - resolution: {integrity: sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.5': - resolution: {integrity: sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.5': - resolution: {integrity: sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1850,8 +1542,8 @@ packages: '@exodus/crypto': optional: true - '@faker-js/faker@10.4.0': - resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} '@floating-ui/core@1.7.3': @@ -2029,10 +1721,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -2040,19 +1728,12 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2257,9 +1938,9 @@ packages: resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} engines: {node: '>=14.0.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@types/babel__core': ^7.1.9 - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: '@types/babel__core': optional: true @@ -2270,7 +1951,7 @@ packages: resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -2279,7 +1960,7 @@ packages: resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -2288,7 +1969,7 @@ packages: resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} engines: {node: '>=20.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -2297,133 +1978,133 @@ packages: resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -2560,65 +2241,65 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/node@4.3.0': - resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} - '@tailwindcss/oxide-android-arm64@4.3.0': - resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.0': - resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.0': - resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.0': - resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2629,24 +2310,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.0': - resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.3.0': - resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + '@tailwindcss/vite@4.3.1': + resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -2890,8 +2571,8 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/node@24.13.1': - resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2931,6 +2612,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.62.0': + resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.60.1': resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2938,6 +2627,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.62.0': + resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.58.0': resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2950,6 +2646,18 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.58.0': resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2958,20 +2666,34 @@ packages: resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.58.0': resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.0': - resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==} + '@typescript-eslint/tsconfig-utils@8.60.1': + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.1': - resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2983,6 +2705,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.62.0': + resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.58.0': resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2991,6 +2720,14 @@ packages: resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.0': resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3003,6 +2740,18 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.60.1': resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3010,6 +2759,20 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.58.0': resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3018,6 +2781,14 @@ packages: resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -3029,11 +2800,11 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3043,20 +2814,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -3073,7 +2844,7 @@ packages: '@vue/babel-plugin-jsx@1.2.5': resolution: {integrity: sha512-zTrNmOd4939H9KsRIGmmzn3q2zvv1mjxkYZHgqHZgDrXz5B1Q3WyGEjO2f+JrmKghvl1JIRcvo63LgM1kH5zFg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' peerDependenciesMeta: '@babel/core': optional: true @@ -3081,7 +2852,7 @@ packages: '@vue/babel-plugin-resolve-type@1.2.5': resolution: {integrity: sha512-U/ibkQrf5sx0XXRnUZD1mo5F7PkpKyTbfXM3a3rC4YnUz6crHEz9Jg09jzzL6QYlXNto/9CePdOg/c87O4Nlfg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@vue/compiler-core@3.5.27': resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} @@ -3101,26 +2872,27 @@ packages: '@vue/devtools-api@7.7.7': resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} - '@vue/devtools-core@8.1.2': - resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==} + '@vue/devtools-core@8.1.3': + resolution: {integrity: sha512-xezkv5/CPH/o5C8PE2Len9MnTJMsctYYQbKbbUiNOJpKd+fRHj27nKDb/sbtYI8NSQduegeQhCJGKRgAiOV6Uw==} peerDependencies: vue: ^3.0.0 '@vue/devtools-kit@7.7.7': resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} - '@vue/devtools-kit@8.1.2': - resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==} + '@vue/devtools-kit@8.1.3': + resolution: {integrity: sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==} '@vue/devtools-shared@7.7.7': resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} - '@vue/devtools-shared@8.1.2': - resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} + '@vue/devtools-shared@8.1.3': + resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==} - '@vue/eslint-config-typescript@14.8.0': - resolution: {integrity: sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==} + '@vue/eslint-config-typescript@14.9.0': + resolution: {integrity: sha512-E3j9hDlfVf10F30MRcLTPY2IIhWIx1nsvkVukk14kTcuA+oBVot9zsP1hzsO+PAMDxV3Fd9FimBJtUBNBL5KFA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true peerDependencies: eslint: ^9.10.0 || ^10.0.0 eslint-plugin-vue: ^9.28.0 || ^10.0.0 @@ -3129,8 +2901,8 @@ packages: typescript: optional: true - '@vue/language-core@3.3.3': - resolution: {integrity: sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==} + '@vue/language-core@3.3.5': + resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -3299,8 +3071,8 @@ packages: atomically@2.1.0: resolution: {integrity: sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==} - autoprefixer@10.5.0: - resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + autoprefixer@10.5.1: + resolution: {integrity: sha512-jwM2pcTuCWUoN70FEvf5XrXyDbUgRURK4FnU8v0jWZZYU/KkVvN9T33mu1sVLFY9JW3kTWzKheEpn6xYLRc/VA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -3319,17 +3091,17 @@ packages: babel-plugin-polyfill-corejs2@0.4.11: resolution: {integrity: sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' babel-plugin-polyfill-corejs3@0.10.6: resolution: {integrity: sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' babel-plugin-polyfill-regenerator@0.6.2: resolution: {integrity: sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' balanced-match@4.0.3: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} @@ -3368,8 +3140,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.12: - resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3411,8 +3183,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3472,8 +3244,8 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001797: - resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} capture-website@4.2.0: resolution: {integrity: sha512-EmkSn36CXTC8tUsS6aNmvvsdpfVTYYkuRp7U5bV9gcJwcDbqqA5c0Op/iskYPKtDdOkuVp61mjn/LLywX0h7cw==} @@ -3827,8 +3599,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3867,8 +3639,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.329: - resolution: {integrity: sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==} + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} emoji-picker-element@1.25.0: resolution: {integrity: sha512-UcUMxqIuneLCsEJ5KpqTD1xaHZyUpg6Oa7uCVe5AMXXpsW3C2TNegbNLXj2/rlbyr6qVMf7lXTFyzvFEarOIUg==} @@ -3896,8 +3668,8 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - enhanced-resolve@5.21.3: - resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -3956,18 +3728,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.5: - resolution: {integrity: sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -4210,8 +3972,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 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'} fraction.js@5.3.4: @@ -4364,8 +4126,8 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - happy-dom@20.10.2: - resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -4748,6 +4510,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joi@18.2.1: resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} @@ -4771,8 +4537,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} 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 jsdom@27.4.0: @@ -4856,8 +4622,8 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} - launch-editor@2.10.0: - resolution: {integrity: sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==} + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -4940,8 +4706,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -5009,19 +4775,19 @@ packages: resolution: {integrity: sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==} peerDependencies: '@types/markdown-it': '*' - markdown-it: '*' + markdown-it: '>=14.2.0' markdown-it-attrs@4.3.1: resolution: {integrity: sha512-/ko6cba+H6gdZ0DOw7BbNMZtfuJTRp9g/IrGIuz8lYc/EfnmWRpaR3CFPnNbVz0LDvF8Gf1hFGPqrQqq7De0rg==} engines: {node: '>=6'} peerDependencies: - markdown-it: '>= 9.0.0' + markdown-it: '>=14.2.0' markdown-it-emoji@3.0.0: resolution: {integrity: sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true marked@17.0.1: @@ -5172,8 +4938,9 @@ packages: encoding: optional: true - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} nopt@7.2.1: resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} @@ -5388,8 +5155,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-color-functional-notation@8.0.4: - resolution: {integrity: sha512-Zn3yPgBFakVXthmA2n1NUMY7gdhuFUB/DrUJ0Eug/d0rl9wahMQZykp4NVTJLGzQrDUwZ2rzjiTeW5udxFNG8A==} + postcss-color-functional-notation@8.0.5: + resolution: {integrity: sha512-Cxr97Vtt2VeJCGaex0JNSU5MViqYtjKmJLHKM+jI7d+qIs0J5xgHEVG6Q2bTCaFJ1yjcFz9s9VmWCibuzk3+MA==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -5473,8 +5240,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-lab-function@8.0.4: - resolution: {integrity: sha512-dqcJSzVasdELD9xqJ1wfP95uzP57J6zFd80c7S3AWK127H9zwqR9Kbk5ZgyIfN2DiMStI7Vq8E7ablXNeTvpew==} + postcss-lab-function@8.0.5: + resolution: {integrity: sha512-ohQnYx1LloPkiLQhAjpt/Y9tAGCGOBOUaxgbcmO+1bDTFzUQCTfdpemOVh6oewI4V2K6q7+Vz8d3rP1glvK3uw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -5517,8 +5284,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-preset-env@11.3.0: - resolution: {integrity: sha512-PpijTuY+NT35vvk7us0pw9lJVrsZZWukjONZsza2Kq1Gag8nrUXRkgdKdxyyhZPJ6R43L3/nLpspUK99TmU9xg==} + postcss-preset-env@11.3.1: + resolution: {integrity: sha512-ox2lu2L0fbuKXB0zRcUFCNii7koS9+fNLFqj+WOKaJ4DU/zZsYkFHOmz73lWNTKx8OHDqnV0R7Si98PIbJXLjQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -5824,15 +5591,15 @@ packages: hasBin: true peerDependencies: rolldown: 1.x || ^1.0.0-beta - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rolldown: optional: true rollup: optional: true - rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6042,8 +5809,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.1: - resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} shiki@3.2.1: resolution: {integrity: sha512-VML/2o1/KGYkEf/stJJ+s9Ypn7jUKQPomGLGYso4JJFMFxVDyPNsjsI3MB3KLjlMOeH44gyaPdXC6rik2WXvUQ==} @@ -6366,8 +6134,8 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tailwindcss@4.3.0: - resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -6434,8 +6202,8 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true - tmp@0.2.6: - resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} to-regex-range@5.0.1: @@ -6706,8 +6474,8 @@ packages: '@vite-pwa/assets-generator': optional: true - vite-plugin-vue-devtools@8.1.2: - resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==} + vite-plugin-vue-devtools@8.1.3: + resolution: {integrity: sha512-KBTUhbTXvY+GsCdShnCHG4WdijEV74KIDxhF8erfSs5g5mS13g/cPRUf4mLpD10qr5FqHYosNt0j6rP5kpiS1Q==} engines: {node: '>=v14.21.3'} peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6762,20 +6530,20 @@ packages: yaml: optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6854,8 +6622,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.3.3: - resolution: {integrity: sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==} + vue-tsc@3.3.5: + resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -7127,11 +6895,6 @@ snapshots: '@akryum/tinypool@0.3.1': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -7157,32 +6920,34 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.26.0': {} - '@babel/core@7.26.0': + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.0 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helpers': 7.26.10 - '@babel/parser': 7.28.5 - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 gensync: 1.0.0-beta.2 @@ -7191,716 +6956,695 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.26.0': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.0.2 - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.25.9': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-compilation-targets@7.25.9': dependencies: - '@babel/compat-data': 7.26.0 - '@babel/helper-validator-option': 7.25.9 - browserslist: 4.28.2 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.26.0)': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/core': 7.26.0 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.26.0)': + '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 regexpu-core: 6.1.1 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.26.0)': + '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.26.0)': - dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.25.9': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.25.9': {} '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.26.0)': + '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-wrap-function': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.25.9(@babel/core@7.26.0)': + '@babel/helper-replace-supers@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-simple-access@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.25.9': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-wrap-function@7.25.9': dependencies: - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helpers@7.26.10': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 - '@babel/parser@7.29.3': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 - '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.26.0)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) - '@babel/traverse': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-classes@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) - '@babel/traverse': 7.25.9 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/template': 7.26.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.29.7 - '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-simple-access': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.29.7) - '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-private-property-in-object@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 regenerator-transform: 0.15.2 - '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-spread@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.26.0(@babel/core@7.26.0)': + '@babel/preset-env@7.26.0(@babel/core@7.29.7)': dependencies: '@babel/compat-data': 7.26.0 - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-validator-option': 7.25.9 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0) - '@babel/plugin-syntax-import-assertions': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.26.0) - '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-block-scoped-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-class-static-block': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-dotall-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-json-strings': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.26.0) - '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-new-target': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-object-super': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-property-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-regexp-modifiers': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-transform-reserved-words': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-template-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-typeof-symbol': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-escapes': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-property-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-sets-regex': 7.25.9(@babel/core@7.26.0) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.26.0) - babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.26.0) - babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.26.0) - babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.26.0) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.25.9(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.29.7) core-js-compat: 3.38.1 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.0)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/types': 7.28.5 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.7 esutils: 2.0.3 '@babel/runtime@7.25.4': dependencies: regenerator-runtime: 0.14.1 - '@babel/template@7.26.9': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/template@7.28.6': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - - '@babel/traverse@7.25.9': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.26.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.26.9 - '@babel/types': 7.28.5 - debug: 4.4.3 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7910,10 +7654,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 '@bufbuild/protobuf@2.5.2': {} @@ -7999,7 +7743,7 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -8031,9 +7775,9 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-alpha-function@2.0.5(postcss@8.5.14)': + '@csstools/postcss-alpha-function@2.0.6(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8046,36 +7790,36 @@ snapshots: postcss: 8.5.14 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@2.0.4(postcss@8.5.14)': + '@csstools/postcss-color-function-display-p3-linear@2.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-color-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-mix-function@4.0.4(postcss@8.5.14)': + '@csstools/postcss-color-mix-function@4.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4(postcss@8.5.14)': + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8096,9 +7840,9 @@ snapshots: '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-contrast-color-function@3.0.4(postcss@8.5.14)': + '@csstools/postcss-contrast-color-function@3.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8123,25 +7867,25 @@ snapshots: '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-gamut-mapping@3.0.4(postcss@8.5.14)': + '@csstools/postcss-gamut-mapping@3.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 postcss: 8.5.14 - '@csstools/postcss-gradients-interpolation-method@6.0.4(postcss@8.5.14)': + '@csstools/postcss-gradients-interpolation-method@6.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-hwb-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-hwb-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8236,9 +7980,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-oklab-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8267,9 +8011,9 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 postcss: 8.5.14 - '@csstools/postcss-relative-color-syntax@4.0.4(postcss@8.5.14)': + '@csstools/postcss-relative-color-syntax@4.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -8335,238 +8079,82 @@ snapshots: dependencies: postcss: 8.5.14 - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/aix-ppc64@0.27.5': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.5': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.5': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.5': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.5': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.5': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.5': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.5': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.5': - optional: true - - '@esbuild/linux-arm64@0.28.0': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-arm@0.27.5': - optional: true - - '@esbuild/linux-arm@0.28.0': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.27.5': - optional: true - - '@esbuild/linux-ia32@0.28.0': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.27.5': - optional: true - - '@esbuild/linux-loong64@0.28.0': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.27.5': - optional: true - - '@esbuild/linux-mips64el@0.28.0': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.27.5': - optional: true - - '@esbuild/linux-ppc64@0.28.0': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.27.5': - optional: true - - '@esbuild/linux-riscv64@0.28.0': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.27.5': - optional: true - - '@esbuild/linux-s390x@0.28.0': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/linux-x64@0.27.5': - optional: true - - '@esbuild/linux-x64@0.28.0': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.27.5': - optional: true - - '@esbuild/netbsd-arm64@0.28.0': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.27.5': - optional: true - - '@esbuild/netbsd-x64@0.28.0': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.27.5': - optional: true - - '@esbuild/openbsd-arm64@0.28.0': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.27.5': - optional: true - - '@esbuild/openbsd-x64@0.28.0': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.27.5': - optional: true - - '@esbuild/openharmony-arm64@0.28.0': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.27.5': - optional: true - - '@esbuild/sunos-x64@0.28.0': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.27.5': - optional: true - - '@esbuild/win32-arm64@0.28.0': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.27.5': - optional: true - - '@esbuild/win32-ia32@0.28.0': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@esbuild/win32-x64@0.27.5': - optional: true - - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': @@ -8600,7 +8188,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 10.2.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8617,7 +8205,7 @@ snapshots: '@exodus/bytes@1.8.0': {} - '@faker-js/faker@10.4.0': {} + '@faker-js/faker@10.5.0': {} '@floating-ui/core@1.7.3': dependencies: @@ -8691,17 +8279,17 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@histoire/app@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/app@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 fuse.js: 7.1.0 shiki: 3.2.1 transitivePeerDependencies: - vite - '@histoire/controls@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/controls@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@codemirror/commands': 6.8.1 '@codemirror/lang-json': 6.0.1 @@ -8710,17 +8298,17 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.36.5 - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 transitivePeerDependencies: - vite - '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': + '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': dependencies: capture-website: 4.2.0(typescript@5.9.3) defu: 6.1.7 fs-extra: 11.2.0 - histoire: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + histoire: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) pathe: 1.1.2 transitivePeerDependencies: - bare-buffer @@ -8729,21 +8317,21 @@ snapshots: - typescript - utf-8-validate - '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 change-case: 5.4.4 globby: 14.1.0 - histoire: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) - launch-editor: 2.10.0 + histoire: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + launch-editor: 2.14.1 pathe: 1.1.2 vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - vite - '@histoire/shared@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/shared@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@histoire/vendors': 1.0.0-beta.1 '@types/fs-extra': 11.0.4 @@ -8751,7 +8339,7 @@ snapshots: chokidar: 4.0.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@histoire/vendors@1.0.0-beta.1': {} @@ -8773,7 +8361,7 @@ snapshots: '@intlify/message-compiler': 11.2.8 '@intlify/shared': 11.2.8 acorn: 8.15.0 - esbuild: 0.25.12 + esbuild: 0.28.1 escodegen: 2.1.0 estree-walker: 2.0.2 jsonc-eslint-parser: 2.4.0 @@ -8794,13 +8382,13 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.61.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.2)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3))) '@intlify/shared': 11.2.8 '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.27)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) debug: 4.4.3 @@ -8841,33 +8429,20 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -9057,122 +8632,122 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.26.0)(rollup@4.61.1)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.2)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 transitivePeerDependencies: - supports-color - '@rollup/plugin-node-resolve@16.0.3(rollup@4.61.1)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.2)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 - '@rollup/plugin-replace@6.0.3(rollup@4.61.1)': + '@rollup/plugin-replace@6.0.3(rollup@4.62.2)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) magic-string: 0.30.21 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 - '@rollup/plugin-terser@1.0.0(rollup@4.61.1)': + '@rollup/plugin-terser@1.0.0(rollup@4.62.2)': dependencies: serialize-javascript: 7.0.5 smob: 1.5.0 terser: 5.31.6 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 - '@rollup/pluginutils@5.1.3(rollup@4.61.1)': + '@rollup/pluginutils@5.1.3(rollup@4.62.2)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 - '@rollup/rollup-android-arm-eabi@4.61.1': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.61.1': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.61.1': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.61.1': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.61.1': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.61.1': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.61.1': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.61.1': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.61.1': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.61.1': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.61.1': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.61.1': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.61.1': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.61.1': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.61.1': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.61.1': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.61.1': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.61.1': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.61.1': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.61.1': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.61.1': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.61.1': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.61.1': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.61.1': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@sentry-internal/browser-utils@10.36.0': @@ -9205,7 +8780,7 @@ snapshots: '@sentry/bundler-plugin-core@3.6.1': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@sentry/babel-plugin-component-annotate': 3.6.1 '@sentry/cli': 2.58.5 dotenv: 16.6.1 @@ -9318,73 +8893,73 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tailwindcss/node@4.3.0': + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.3 - jiti: 2.6.1 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.0 + tailwindcss: 4.3.1 - '@tailwindcss/oxide-android-arm64@4.3.0': + '@tailwindcss/oxide-android-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.0': + '@tailwindcss/oxide-darwin-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.0': + '@tailwindcss/oxide-darwin-x64@4.3.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.0': + '@tailwindcss/oxide-freebsd-x64@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.0': + '@tailwindcss/oxide-linux-x64-musl@4.3.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.0': + '@tailwindcss/oxide-wasm32-wasi@4.3.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': optional: true - '@tailwindcss/oxide@4.3.0': + '@tailwindcss/oxide@4.3.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-x64': 4.3.0 - '@tailwindcss/oxide-freebsd-x64': 4.3.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-x64-musl': 4.3.0 - '@tailwindcss/oxide-wasm32-wasi': 4.3.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + tailwindcss: 4.3.1 + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@tiptap/core@3.17.0(@tiptap/pm@3.17.0)': dependencies: @@ -9616,7 +9191,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/hast@3.0.4': dependencies: @@ -9628,7 +9203,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/linkify-it@5.0.0': {} @@ -9645,7 +9220,7 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/node@24.13.1': + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 @@ -9669,11 +9244,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 optional: true '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': @@ -9692,6 +9267,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/type-utils': 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.60.1 @@ -9704,10 +9295,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9715,8 +9318,26 @@ snapshots: '@typescript-eslint/project-service@8.60.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9732,15 +9353,29 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -9756,10 +9391,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/types@8.60.1': {} + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/types@8.62.0': {} + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) @@ -9790,6 +9441,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9801,6 +9482,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 @@ -9811,52 +9514,62 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.0 + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.0 + + '@typescript-eslint/visitor-keys@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + eslint-visitor-keys: 5.0.0 + '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vue: 3.5.27(typescript@5.9.3) - '@vitest/expect@4.1.8': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.8': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -9874,30 +9587,30 @@ snapshots: '@vue/babel-helper-vue-transform-on@1.2.5': {} - '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.26.0)': + '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.29.7)': dependencies: - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.29.7) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@vue/babel-helper-vue-transform-on': 1.2.5 - '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.26.0) + '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.29.7) html-tags: 3.3.1 svg-tags: 1.0.0 optionalDependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.26.0)': + '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.29.7)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/parser': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.7 '@vue/compiler-sfc': 3.5.27 transitivePeerDependencies: - supports-color @@ -9938,10 +9651,10 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@8.1.2(vue@3.5.27(typescript@5.9.3))': + '@vue/devtools-core@8.1.3(vue@3.5.27(typescript@5.9.3))': dependencies: - '@vue/devtools-kit': 8.1.2 - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-kit': 8.1.3 + '@vue/devtools-shared': 8.1.3 vue: 3.5.27(typescript@5.9.3) '@vue/devtools-kit@7.7.7': @@ -9954,9 +9667,9 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 - '@vue/devtools-kit@8.1.2': + '@vue/devtools-kit@8.1.3': dependencies: - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-shared': 8.1.3 birpc: 2.6.1 hookable: 5.5.3 perfect-debounce: 2.0.0 @@ -9965,13 +9678,13 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/devtools-shared@8.1.2': {} + '@vue/devtools-shared@8.1.3': {} - '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) @@ -9980,7 +9693,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/language-core@3.3.3': + '@vue/language-core@3.3.5': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -10150,10 +9863,10 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - autoprefixer@10.5.0(postcss@8.5.14): + autoprefixer@10.5.1(postcss@8.5.14): dependencies: - browserslist: 4.28.2 - caniuse-lite: 1.0.30001797 + browserslist: 4.28.4 + caniuse-lite: 1.0.30001799 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.14 @@ -10166,34 +9879,34 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug b4a@1.6.7: {} - babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.26.0): + babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.29.7): dependencies: - '@babel/compat-data': 7.26.0 - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.10.6(@babel/core@7.26.0): + babel-plugin-polyfill-corejs3@0.10.6(@babel/core@7.29.7): dependencies: - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) core-js-compat: 3.38.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.26.0): + babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.29.7): dependencies: - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -10226,7 +9939,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.12: {} + baseline-browser-mapping@2.10.38: {} basic-ftp@5.2.2: {} @@ -10269,13 +9982,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.4: dependencies: - baseline-browser-mapping: 2.10.12 - caniuse-lite: 1.0.30001797 - electron-to-chromium: 1.5.329 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.4) buffer-crc32@0.2.13: {} @@ -10283,7 +9996,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 buffer@5.7.1: dependencies: @@ -10335,7 +10048,7 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001797: {} + caniuse-lite@1.0.30001799: {} capture-website@4.2.0(typescript@5.9.3): dependencies: @@ -10471,13 +10184,13 @@ snapshots: core-js-compat@3.38.1: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.4 cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -10670,7 +10383,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.0: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10711,7 +10424,7 @@ snapshots: dependencies: jake: 10.9.2 - electron-to-chromium@1.5.329: {} + electron-to-chromium@1.5.376: {} emoji-picker-element@1.25.0: {} @@ -10731,7 +10444,7 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.21.3: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -10836,92 +10549,34 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild@0.25.12: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - esbuild@0.27.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.5 - '@esbuild/android-arm': 0.27.5 - '@esbuild/android-arm64': 0.27.5 - '@esbuild/android-x64': 0.27.5 - '@esbuild/darwin-arm64': 0.27.5 - '@esbuild/darwin-x64': 0.27.5 - '@esbuild/freebsd-arm64': 0.27.5 - '@esbuild/freebsd-x64': 0.27.5 - '@esbuild/linux-arm': 0.27.5 - '@esbuild/linux-arm64': 0.27.5 - '@esbuild/linux-ia32': 0.27.5 - '@esbuild/linux-loong64': 0.27.5 - '@esbuild/linux-mips64el': 0.27.5 - '@esbuild/linux-ppc64': 0.27.5 - '@esbuild/linux-riscv64': 0.27.5 - '@esbuild/linux-s390x': 0.27.5 - '@esbuild/linux-x64': 0.27.5 - '@esbuild/netbsd-arm64': 0.27.5 - '@esbuild/netbsd-x64': 0.27.5 - '@esbuild/openbsd-arm64': 0.27.5 - '@esbuild/openbsd-x64': 0.27.5 - '@esbuild/openharmony-arm64': 0.27.5 - '@esbuild/sunos-x64': 0.27.5 - '@esbuild/win32-arm64': 0.27.5 - '@esbuild/win32-ia32': 0.27.5 - '@esbuild/win32-x64': 0.27.5 - - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -10948,7 +10603,7 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -10959,7 +10614,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -11057,7 +10712,7 @@ snapshots: dependencies: chardet: 0.7.0 iconv-lite: 0.4.24 - tmp: 0.2.6 + tmp: 0.2.7 extract-zip@2.0.1: dependencies: @@ -11181,7 +10836,7 @@ 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 @@ -11362,9 +11017,9 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - happy-dom@20.10.2: + happy-dom@20.10.6: dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -11425,12 +11080,12 @@ snapshots: highlight.js@11.11.1: {} - histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): + histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@akryum/tinypool': 0.3.1 - '@histoire/app': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/app': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 '@types/markdown-it': 14.1.2 birpc: 0.2.19 @@ -11444,9 +11099,9 @@ snapshots: gray-matter: 4.0.3 jiti: 2.6.1 jsdom: 27.4.0 - markdown-it: 14.1.1 - markdown-it-anchor: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.1) - markdown-it-attrs: 4.3.1(markdown-it@14.1.1) + markdown-it: 14.2.0 + markdown-it-anchor: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0) + markdown-it-attrs: 4.3.1(markdown-it@14.2.0) markdown-it-emoji: 3.0.0 micromatch: 4.0.8 mrmime: 2.0.0 @@ -11455,8 +11110,8 @@ snapshots: sade: 1.8.1 shiki: 3.2.1 sirv: 3.0.2 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@exodus/crypto' - '@types/node' @@ -11775,6 +11430,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + joi@18.2.1: dependencies: '@hapi/address': 5.1.1 @@ -11804,7 +11461,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -11891,10 +11548,10 @@ snapshots: dependencies: package-json: 10.0.1 - launch-editor@2.10.0: + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 - shell-quote: 1.8.1 + shell-quote: 1.8.4 leven@3.1.0: {} @@ -11954,7 +11611,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: + linkify-it@5.0.1: dependencies: uc.micro: 2.1.0 @@ -12011,22 +11668,22 @@ snapshots: map-obj@4.3.0: {} - markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.1): + markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0): dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 + markdown-it: 14.2.0 - markdown-it-attrs@4.3.1(markdown-it@14.1.1): + markdown-it-attrs@4.3.1(markdown-it@14.2.0): dependencies: - markdown-it: 14.1.1 + markdown-it: 14.2.0 markdown-it-emoji@3.0.0: {} - markdown-it@14.1.1: + markdown-it@14.2.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.1 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -12154,7 +11811,7 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-releases@2.0.36: {} + node-releases@2.0.48: {} nopt@7.2.1: dependencies: @@ -12384,9 +12041,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@8.0.4(postcss@8.5.14): + postcss-color-functional-notation@8.0.5(postcss@8.5.14): dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -12480,9 +12137,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-lab-function@8.0.4(postcss@8.5.14): + postcss-lab-function@8.0.5(postcss@8.5.14): dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -12521,23 +12178,23 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-preset-env@11.3.0(postcss@8.5.14): + postcss-preset-env@11.3.1(postcss@8.5.14): dependencies: - '@csstools/postcss-alpha-function': 2.0.5(postcss@8.5.14) + '@csstools/postcss-alpha-function': 2.0.6(postcss@8.5.14) '@csstools/postcss-cascade-layers': 6.0.0(postcss@8.5.14) - '@csstools/postcss-color-function': 5.0.4(postcss@8.5.14) - '@csstools/postcss-color-function-display-p3-linear': 2.0.4(postcss@8.5.14) - '@csstools/postcss-color-mix-function': 4.0.4(postcss@8.5.14) - '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.4(postcss@8.5.14) + '@csstools/postcss-color-function': 5.0.5(postcss@8.5.14) + '@csstools/postcss-color-function-display-p3-linear': 2.0.5(postcss@8.5.14) + '@csstools/postcss-color-mix-function': 4.0.5(postcss@8.5.14) + '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.5(postcss@8.5.14) '@csstools/postcss-container-rule-prelude-list': 1.0.1(postcss@8.5.14) '@csstools/postcss-content-alt-text': 3.0.1(postcss@8.5.14) - '@csstools/postcss-contrast-color-function': 3.0.4(postcss@8.5.14) + '@csstools/postcss-contrast-color-function': 3.0.5(postcss@8.5.14) '@csstools/postcss-exponential-functions': 3.0.3(postcss@8.5.14) '@csstools/postcss-font-format-keywords': 5.0.0(postcss@8.5.14) '@csstools/postcss-font-width-property': 1.0.0(postcss@8.5.14) - '@csstools/postcss-gamut-mapping': 3.0.4(postcss@8.5.14) - '@csstools/postcss-gradients-interpolation-method': 6.0.4(postcss@8.5.14) - '@csstools/postcss-hwb-function': 5.0.4(postcss@8.5.14) + '@csstools/postcss-gamut-mapping': 3.0.5(postcss@8.5.14) + '@csstools/postcss-gradients-interpolation-method': 6.0.5(postcss@8.5.14) + '@csstools/postcss-hwb-function': 5.0.5(postcss@8.5.14) '@csstools/postcss-ic-unit': 5.0.1(postcss@8.5.14) '@csstools/postcss-image-function': 1.0.0(postcss@8.5.14) '@csstools/postcss-initial': 3.0.0(postcss@8.5.14) @@ -12553,12 +12210,12 @@ snapshots: '@csstools/postcss-mixins': 1.0.0(postcss@8.5.14) '@csstools/postcss-nested-calc': 5.0.0(postcss@8.5.14) '@csstools/postcss-normalize-display-values': 5.0.1(postcss@8.5.14) - '@csstools/postcss-oklab-function': 5.0.4(postcss@8.5.14) + '@csstools/postcss-oklab-function': 5.0.5(postcss@8.5.14) '@csstools/postcss-position-area-property': 2.0.0(postcss@8.5.14) '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/postcss-property-rule-prelude-list': 2.0.0(postcss@8.5.14) '@csstools/postcss-random-function': 3.0.3(postcss@8.5.14) - '@csstools/postcss-relative-color-syntax': 4.0.4(postcss@8.5.14) + '@csstools/postcss-relative-color-syntax': 4.0.5(postcss@8.5.14) '@csstools/postcss-scope-pseudo-class': 5.0.0(postcss@8.5.14) '@csstools/postcss-sign-functions': 2.0.3(postcss@8.5.14) '@csstools/postcss-stepped-value-functions': 5.0.3(postcss@8.5.14) @@ -12567,8 +12224,8 @@ snapshots: '@csstools/postcss-text-decoration-shorthand': 5.0.3(postcss@8.5.14) '@csstools/postcss-trigonometric-functions': 5.0.3(postcss@8.5.14) '@csstools/postcss-unset-value': 5.0.0(postcss@8.5.14) - autoprefixer: 10.5.0(postcss@8.5.14) - browserslist: 4.28.2 + autoprefixer: 10.5.1(postcss@8.5.14) + browserslist: 4.28.4 css-blank-pseudo: 8.0.1(postcss@8.5.14) css-has-pseudo: 8.0.0(postcss@8.5.14) css-prefers-color-scheme: 11.0.0(postcss@8.5.14) @@ -12576,7 +12233,7 @@ snapshots: postcss: 8.5.14 postcss-attribute-case-insensitive: 8.0.0(postcss@8.5.14) postcss-clamp: 4.1.0(postcss@8.5.14) - postcss-color-functional-notation: 8.0.4(postcss@8.5.14) + postcss-color-functional-notation: 8.0.5(postcss@8.5.14) postcss-color-hex-alpha: 11.0.0(postcss@8.5.14) postcss-color-rebeccapurple: 11.0.0(postcss@8.5.14) postcss-custom-media: 12.0.1(postcss@8.5.14) @@ -12589,7 +12246,7 @@ snapshots: postcss-font-variant: 5.0.0(postcss@8.5.14) postcss-gap-properties: 7.0.0(postcss@8.5.14) postcss-image-set-function: 8.0.0(postcss@8.5.14) - postcss-lab-function: 8.0.4(postcss@8.5.14) + postcss-lab-function: 8.0.5(postcss@8.5.14) postcss-logical: 9.0.0(postcss@8.5.14) postcss-nesting: 14.0.0(postcss@8.5.14) postcss-opacity-percentage: 3.0.0(postcss@8.5.14) @@ -12704,7 +12361,7 @@ snapshots: prosemirror-markdown@1.13.1: dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 + markdown-it: 14.2.0 prosemirror-model: 1.25.0 prosemirror-menu@1.2.4: @@ -12957,44 +12614,44 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.11(rollup@4.61.1): + rollup-plugin-visualizer@6.0.11(rollup@4.62.2): dependencies: open: 8.4.2 picomatch: 4.0.4 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.2 - rollup@4.61.1: + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.61.1 - '@rollup/rollup-android-arm64': 4.61.1 - '@rollup/rollup-darwin-arm64': 4.61.1 - '@rollup/rollup-darwin-x64': 4.61.1 - '@rollup/rollup-freebsd-arm64': 4.61.1 - '@rollup/rollup-freebsd-x64': 4.61.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 - '@rollup/rollup-linux-arm-musleabihf': 4.61.1 - '@rollup/rollup-linux-arm64-gnu': 4.61.1 - '@rollup/rollup-linux-arm64-musl': 4.61.1 - '@rollup/rollup-linux-loong64-gnu': 4.61.1 - '@rollup/rollup-linux-loong64-musl': 4.61.1 - '@rollup/rollup-linux-ppc64-gnu': 4.61.1 - '@rollup/rollup-linux-ppc64-musl': 4.61.1 - '@rollup/rollup-linux-riscv64-gnu': 4.61.1 - '@rollup/rollup-linux-riscv64-musl': 4.61.1 - '@rollup/rollup-linux-s390x-gnu': 4.61.1 - '@rollup/rollup-linux-x64-gnu': 4.61.1 - '@rollup/rollup-linux-x64-musl': 4.61.1 - '@rollup/rollup-openbsd-x64': 4.61.1 - '@rollup/rollup-openharmony-arm64': 4.61.1 - '@rollup/rollup-win32-arm64-msvc': 4.61.1 - '@rollup/rollup-win32-ia32-msvc': 4.61.1 - '@rollup/rollup-win32-x64-gnu': 4.61.1 - '@rollup/rollup-win32-x64-msvc': 4.61.1 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -13185,7 +12842,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.1: {} + shell-quote@1.8.4: {} shiki@3.2.1: dependencies: @@ -13582,7 +13239,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tailwindcss@4.3.0: {} + tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -13653,7 +13310,7 @@ snapshots: dependencies: tldts-core: 7.0.19 - tmp@0.2.6: {} + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: @@ -13853,9 +13510,9 @@ snapshots: upath@1.2.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -13897,23 +13554,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-dev-rpc@1.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-dev-rpc@1.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: birpc: 2.6.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-hot-client: 2.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - vite-hot-client@2.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-hot-client@2.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-node@3.2.4(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): + vite-node@3.2.4(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -13928,7 +13585,7 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-inspect@11.3.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: ansis: 4.1.0 debug: 4.4.3 @@ -13938,48 +13595,48 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-dev-rpc: 1.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - supports-color - vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) workbox-build: 7.4.1 workbox-window: 7.4.1 transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.2(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): + vite-plugin-vue-devtools@8.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 8.1.2(vue@3.5.27(typescript@5.9.3)) - '@vue/devtools-kit': 8.1.2 - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-core': 8.1.3(vue@3.5.27(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.3 + '@vue/devtools-shared': 8.1.3 sirv: 3.0.2 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-plugin-inspect: 11.3.3(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - vite-plugin-vue-inspector: 6.0.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite-plugin-vue-inspector: 6.0.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@6.0.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-vue-inspector@6.0.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - '@babel/core': 7.26.0 - '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.26.0) - '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.0) - '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.29.7) '@vue/compiler-dom': 3.5.27 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -13991,16 +13648,16 @@ snapshots: transitivePeerDependencies: - supports-color - vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): + vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: - esbuild: 0.27.5 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 - rollup: 4.61.1 + rollup: 4.62.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 @@ -14009,15 +13666,15 @@ snapshots: terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.8(@types/node@24.13.1)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -14029,11 +13686,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.1 - happy-dom: 20.10.2 + '@types/node': 24.13.2 + happy-dom: 20.10.6 jsdom: 27.4.0 transitivePeerDependencies: - msw @@ -14086,10 +13743,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.3.3(typescript@5.9.3): + vue-tsc@3.3.5(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.3 + '@vue/language-core': 3.3.5 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): @@ -14234,13 +13891,13 @@ snapshots: workbox-build@7.4.1: dependencies: '@apideck/better-ajv-errors': 0.3.6(ajv@8.18.0) - '@babel/core': 7.26.0 - '@babel/preset-env': 7.26.0(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.26.0(@babel/core@7.29.7) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.26.0)(rollup@4.61.1) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.61.1) - '@rollup/plugin-replace': 6.0.3(rollup@4.61.1) - '@rollup/plugin-terser': 1.0.0(rollup@4.61.1) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.62.2) '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 ajv: 8.18.0 common-tags: 1.8.2 @@ -14249,7 +13906,7 @@ snapshots: fs-extra: 9.1.0 glob: 11.1.0 pretty-bytes: 5.6.0 - rollup: 4.61.1 + rollup: 4.62.2 source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 diff --git a/frontend/public/images/icons/favicon-tracking-32x32.png b/frontend/public/images/icons/favicon-tracking-32x32.png new file mode 100644 index 000000000..d3867376f Binary files /dev/null and b/frontend/public/images/icons/favicon-tracking-32x32.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 08688b1bb..760c18edc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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() diff --git a/frontend/src/components/time-tracking/TimeEntryList.vue b/frontend/src/components/time-tracking/TimeEntryList.vue new file mode 100644 index 000000000..7ef47b51b --- /dev/null +++ b/frontend/src/components/time-tracking/TimeEntryList.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/frontend/src/components/time-tracking/TimerBadge.vue b/frontend/src/components/time-tracking/TimerBadge.vue new file mode 100644 index 000000000..9a52553cb --- /dev/null +++ b/frontend/src/components/time-tracking/TimerBadge.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/frontend/src/composables/useGlobalNow.ts b/frontend/src/composables/useGlobalNow.ts index 83d9cf9ef..c5e3510e7 100644 --- a/frontend/src/composables/useGlobalNow.ts +++ b/frontend/src/composables/useGlobalNow.ts @@ -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, diff --git a/frontend/src/composables/useTaskList.test.ts b/frontend/src/composables/useTaskList.test.ts new file mode 100644 index 000000000..9703ab29a --- /dev/null +++ b/frontend/src/composables/useTaskList.test.ts @@ -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({}) + }) +}) diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index b595e5e09..0f4ac40c1 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -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({...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} diff --git a/frontend/src/composables/useTimeTrackingFavicon.ts b/frontend/src/composables/useTimeTrackingFavicon.ts new file mode 100644 index 000000000..43f24b8a7 --- /dev/null +++ b/frontend/src/composables/useTimeTrackingFavicon.ts @@ -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('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)) +}) diff --git a/frontend/src/constants/proFeatures.ts b/frontend/src/constants/proFeatures.ts new file mode 100644 index 000000000..4e2af18ec --- /dev/null +++ b/frontend/src/constants/proFeatures.ts @@ -0,0 +1,8 @@ +// Licensed "pro" features the server may advertise via /info's enabled_pro_features. +// Use these instead of bare strings when calling configStore.isProFeatureEnabled. +export const PRO_FEATURE = { + ADMIN_PANEL: 'admin_panel', + TIME_TRACKING: 'time_tracking', +} as const + +export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE] diff --git a/frontend/src/constants/redirectHash.ts b/frontend/src/constants/redirectHash.ts new file mode 100644 index 000000000..1dd648e58 --- /dev/null +++ b/frontend/src/constants/redirectHash.ts @@ -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=' diff --git a/frontend/src/helpers/auth.test.ts b/frontend/src/helpers/auth.test.ts new file mode 100644 index 000000000..c57f896b8 --- /dev/null +++ b/frontend/src/helpers/auth.test.ts @@ -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]) + }) +}) diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index 1d46258dc..04ed29f2e 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -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 | 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 { + 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 { + // 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 { } 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 { // 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 { 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 { } 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() } } diff --git a/frontend/src/helpers/inputPrompt.ts b/frontend/src/helpers/inputPrompt.ts index 11dd3325f..d8de526ca 100644 --- a/frontend/src/helpers/inputPrompt.ts +++ b/frontend/src/helpers/inputPrompt.ts @@ -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 { +export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise { return new Promise((resolve) => { const id = 'link-input-' + createRandomID() + // Append inside the open task (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 . 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 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) diff --git a/frontend/src/helpers/time/formatDate.ts b/frontend/src/helpers/time/formatDate.ts index 4ff9a4da5..ed7f4a3d7 100644 --- a/frontend/src/helpers/time/formatDate.ts +++ b/frontend/src/helpers/time/formatDate.ts @@ -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) : '' } diff --git a/frontend/src/helpers/time/smartFillStart.test.ts b/frontend/src/helpers/time/smartFillStart.test.ts new file mode 100644 index 000000000..e48caf130 --- /dev/null +++ b/frontend/src/helpers/time/smartFillStart.test.ts @@ -0,0 +1,57 @@ +import {describe, it, expect} from 'vitest' + +import {smartFillStart} from './smartFillStart' +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +function entry(startTime: Date, endTime: Date | null): ITimeEntry { + return { + id: 1, + userId: 1, + taskId: 0, + projectId: 0, + startTime, + endTime, + comment: '', + created: startTime, + updated: startTime, + maxPermission: null, + } +} + +describe('smartFillStart', () => { + const now = new Date('2026-06-07T15:30:00') + + it('continues from the latest entry end time', () => { + const entries = [ + entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')), + entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')), + ] + expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00')) + }) + + it('ignores still-running entries (no end) when picking the latest end', () => { + const entries = [ + entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')), + entry(new Date('2026-06-07T13:00:00'), null), + ] + expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00')) + }) + + it('falls back to the default start time on the current day when there are no entries', () => { + expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00')) + }) + + it('falls back to 09:00 when no default is configured', () => { + expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00')) + }) + + it('caps the default start at now when it would be in the future (before 09:00)', () => { + const beforeNine = new Date('2026-06-07T07:30:00') + expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine) + }) + + it('caps a future last-entry end at now', () => { + const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))] + expect(smartFillStart(entries, '09:00', now)).toEqual(now) + }) +}) diff --git a/frontend/src/helpers/time/smartFillStart.ts b/frontend/src/helpers/time/smartFillStart.ts new file mode 100644 index 000000000..0a84a7eea --- /dev/null +++ b/frontend/src/helpers/time/smartFillStart.ts @@ -0,0 +1,24 @@ +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +// The smart-clock start time: continue from the most recent entry's end so +// consecutive entries don't overlap or leave gaps; with no completed entry to +// continue from, fall back to the user's configured default start (HH:MM) on +// the given day. +export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date { + // The filled range ends at now, so a start after now would be inverted (and + // rejected on save). Cap at now — e.g. the 09:00 fallback before 9am. + const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start) + + const lastEnd = recentEntries + .map(entry => entry.endTime) + .filter((end): end is Date => end !== null) + .sort((a, b) => b.getTime() - a.getTime())[0] + if (lastEnd !== undefined) { + return cap(new Date(lastEnd)) + } + + const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number) + const start = new Date(now) + start.setHours(hours || 0, minutes || 0, 0, 0) + return cap(start) +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 703a9940f..d7dae0060 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -30,6 +30,7 @@ export const SUPPORTED_LOCALES = { 'ja-JP': '日本語', 'hu-HU': 'Magyar', 'ar-SA': 'اَلْعَرَبِيَّةُ', + 'fa-IR': 'فارسی', 'sl-SI': 'Slovenščina', 'pt-BR': 'Português Brasileiro', 'hr-HR': 'Hrvatski', @@ -52,7 +53,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en' export type ISOLanguage = string -const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const +const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const export function isRTLLanguage(locale: SupportedLocale): boolean { return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number]) diff --git a/frontend/src/i18n/lang/ar-SA.json b/frontend/src/i18n/lang/ar-SA.json index f9338b33a..26e0d5ef2 100644 --- a/frontend/src/i18n/lang/ar-SA.json +++ b/frontend/src/i18n/lang/ar-SA.json @@ -284,8 +284,7 @@ "default": "افتراضي", "month": "شهر", "day": "يوم", - "hour": "ساعة", - "range": "نطاق التاريخ" + "hour": "ساعة" }, "table": { "title": "جدول", @@ -294,7 +293,6 @@ "kanban": { "title": "Kanban", "limit": "الحد: {limit}", - "noLimit": "غير محدد", "doneBucket": "حافظة المهام المكتملة", "doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.", "doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.", diff --git a/frontend/src/i18n/lang/bg-BG.json b/frontend/src/i18n/lang/bg-BG.json index 28b2c292f..0a4458c07 100644 --- a/frontend/src/i18n/lang/bg-BG.json +++ b/frontend/src/i18n/lang/bg-BG.json @@ -314,8 +314,7 @@ "default": "По подразбиране", "month": "Месец", "day": "Ден", - "hour": "Час", - "range": "Времеви диапазон" + "hour": "Час" }, "table": { "title": "Таблица", @@ -324,7 +323,6 @@ "kanban": { "title": "Канбан", "limit": "Лимит: {limit}", - "noLimit": "Не е зададен", "doneBucket": "Колона за завършени", "doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.", "doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.", diff --git a/frontend/src/i18n/lang/cs-CZ.json b/frontend/src/i18n/lang/cs-CZ.json index b6ca3a20b..6a8d3c523 100644 --- a/frontend/src/i18n/lang/cs-CZ.json +++ b/frontend/src/i18n/lang/cs-CZ.json @@ -383,7 +383,6 @@ "month": "Měsíc", "day": "Den", "hour": "Hodina", - "range": "Časové období", "chartLabel": "Projektový Ganttův diagram", "taskBarsForRow": "Chlívky pro řádek {rowId}", "taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.", @@ -412,7 +411,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nenastaveno", "doneBucket": "Sloupec \"Hotovo\"", "doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.", "doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.", diff --git a/frontend/src/i18n/lang/de-DE.json b/frontend/src/i18n/lang/de-DE.json index bf093f624..a87ae711f 100644 --- a/frontend/src/i18n/lang/de-DE.json +++ b/frontend/src/i18n/lang/de-DE.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "JJJJ/MM/TT" }, "timeFormat": "Zeitformat", + "timeTrackingDefaultStart": "Startzeit für die Zeiterfassung", "timeFormatOptions": { "12h": "12 Stunden (AM/PM)", "24h": "24 Stunden (HH:mm)" @@ -392,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": { @@ -470,7 +472,6 @@ "month": "Monat", "day": "Tag", "hour": "Stunde", - "range": "Zeitraum", "chartLabel": "Projekt Gantt-Diagramm", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", @@ -499,7 +500,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nicht gesetzt", "doneBucket": "Erledigt Spalte", "doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.", @@ -783,7 +783,10 @@ "closeDialog": "Dialog schließen", "closeQuickActions": "Schnellaktionen schließen", "skipToContent": "Überspringen und zum Hauptinhalt gehen", - "sortBy": "Sortieren nach" + "sortBy": "Sortieren nach", + "dateRange": "Zeitraum", + "notSet": "Nicht festgelegt", + "user": "Benutzer:in" }, "input": { "projectColor": "Projektfarbe", @@ -993,6 +996,7 @@ "repeatAfter": "Wiederholung setzen", "percentDone": "Fortschritt einstellen", "attachments": "Anhänge hinzufügen", + "timeTracking": "Zeit erfassen", "relatedTasks": "Beziehung hinzufügen", "moveProject": "Verschieben", "duplicate": "Duplizieren", @@ -1462,6 +1466,32 @@ "frontendVersion": "Frontend-Version: {version}", "apiVersion": "API-Version: {version}" }, + "timeTracking": { + "title": "Zeiterfassung", + "stop": "Timer stoppen", + "logTime": "Zeit buchen", + "editEntry": "Eintrag bearbeiten", + "form": { + "task": "Aufgabe", + "taskSearch": "Nach einer Aufgabe suchen…", + "commentPlaceholder": "Woran hast du gearbeitet?", + "save": "Speichern", + "startTimer": "Timer starten", + "update": "Eintrag aktualisieren", + "smartFill": "Vom letzten Eintrag ausfüllen" + }, + "list": { + "emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.", + "emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.", + "total": "Gesamt", + "time": "Uhrzeit", + "duration": "Dauer" + }, + "browse": { + "selectRange": "Bereich wählen", + "userSearch": "Nach einer:m Benutzer:in suchen…" + } + }, "time": { "units": { "seconds": "Sekunde|Sekunden", diff --git a/frontend/src/i18n/lang/de-swiss.json b/frontend/src/i18n/lang/de-swiss.json index 541d992f1..b95b8ff20 100644 --- a/frontend/src/i18n/lang/de-swiss.json +++ b/frontend/src/i18n/lang/de-swiss.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "JJJJ/MM/TT" }, "timeFormat": "Zeitformat", + "timeTrackingDefaultStart": "Startzeit für die Zeiterfassung", "timeFormatOptions": { "12h": "12 Stunden (AM/PM)", "24h": "24 Stunden (HH:mm)" @@ -392,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": { @@ -470,7 +472,6 @@ "month": "Monat", "day": "Tag", "hour": "Stunde", - "range": "Zeitraum", "chartLabel": "Projekt Gantt-Diagramm", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", @@ -499,7 +500,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nicht gesetzt", "doneBucket": "Erledigt Spalte", "doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.", @@ -783,7 +783,10 @@ "closeDialog": "Dialog schließen", "closeQuickActions": "Schnellaktionen schließen", "skipToContent": "Überspringen und zum Hauptinhalt gehen", - "sortBy": "Sortieren nach" + "sortBy": "Sortieren nach", + "dateRange": "Zeitraum", + "notSet": "Nicht festgelegt", + "user": "Benutzer:in" }, "input": { "projectColor": "Projektfarbe", @@ -993,6 +996,7 @@ "repeatAfter": "Wiederholung setzen", "percentDone": "Fortschritt einstellen", "attachments": "Anhänge hinzufügen", + "timeTracking": "Zeit erfassen", "relatedTasks": "Beziehung hinzufügen", "moveProject": "Verschieben", "duplicate": "Duplizieren", @@ -1462,6 +1466,32 @@ "frontendVersion": "Frontend-Version: {version}", "apiVersion": "API-Version: {version}" }, + "timeTracking": { + "title": "Zeiterfassung", + "stop": "Timer stoppen", + "logTime": "Zeit buchen", + "editEntry": "Eintrag bearbeiten", + "form": { + "task": "Aufgabe", + "taskSearch": "Nach einer Aufgabe suchen…", + "commentPlaceholder": "Woran hast du gearbeitet?", + "save": "Speichern", + "startTimer": "Timer starten", + "update": "Eintrag aktualisieren", + "smartFill": "Vom letzten Eintrag ausfüllen" + }, + "list": { + "emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.", + "emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.", + "total": "Gesamt", + "time": "Uhrzeit", + "duration": "Dauer" + }, + "browse": { + "selectRange": "Bereich wählen", + "userSearch": "Nach einer:m Benutzer:in suchen…" + } + }, "time": { "units": { "seconds": "Sekunde|Sekunden", diff --git a/frontend/src/i18n/lang/el-GR.json b/frontend/src/i18n/lang/el-GR.json index 5eee25a79..14837735b 100644 --- a/frontend/src/i18n/lang/el-GR.json +++ b/frontend/src/i18n/lang/el-GR.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ" }, "timeFormat": "Μορφή ώρας", + "timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα", "timeFormatOptions": { "12h": "12 ώρες (ΠΜ/ΜΜ)", "24h": "24 ώρες (ΩΩ:ΛΛ)" @@ -470,7 +471,6 @@ "month": "Μήνας", "day": "Ημέρα", "hour": "Ώρα", - "range": "Εύρος Ημερομηνιών", "chartLabel": "Γράφημα Gantt Έργου", "taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}", "taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.", @@ -499,7 +499,6 @@ "kanban": { "title": "Kanban", "limit": "Όριο: {limit}", - "noLimit": "Δεν έχει οριστεί", "doneBucket": "Κάδος για ολοκληρωμένα", "doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.", "doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.", @@ -783,7 +782,10 @@ "closeDialog": "Κλείσμο του διαλόγου", "closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών", "skipToContent": "Μετάβαση στο κύριο περιεχόμενο", - "sortBy": "Ταξινόμηση ανά" + "sortBy": "Ταξινόμηση ανά", + "dateRange": "Εύρος ημερομηνιών", + "notSet": "Μη ορισμένο", + "user": "Χρήστης" }, "input": { "projectColor": "Χρώμα έργου", @@ -993,6 +995,7 @@ "repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος", "percentDone": "Ορισμός Προόδου", "attachments": "Προσθήκη Συνημμένων", + "timeTracking": "Χρόνος ίχνους", "relatedTasks": "Προσθήκη Συσχέτισης", "moveProject": "Μετακίνηση", "duplicate": "Αντιγραφή", @@ -1462,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": "δευτερόλεπτο|δευτερόλεπτα", diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 3b90d69ed..82e7d0e5a 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -172,6 +172,7 @@ "yyyy\/mm\/dd": "YYYY\/MM\/DD" }, "timeFormat": "Time format", + "timeTrackingDefaultStart": "Time tracking smart-fill start time", "timeFormatOptions": { "12h": "12-hour (AM/PM)", "24h": "24-hour (HH:mm)" @@ -392,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": { @@ -470,7 +472,6 @@ "month": "Month", "day": "Day", "hour": "Hour", - "range": "Date Range", "chartLabel": "Project Gantt Chart", "taskBarsForRow": "Task bars for row {rowId}", "taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.", @@ -499,7 +500,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Not Set", "doneBucket": "Done bucket", "doneBucketHint": "All tasks moved into this bucket will automatically marked as done.", "doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.", @@ -783,7 +783,10 @@ "closeDialog": "Close dialog", "closeQuickActions": "Close quick actions", "skipToContent": "Skip to main content", - "sortBy": "Sort by" + "sortBy": "Sort by", + "dateRange": "Date range", + "notSet": "Not set", + "user": "User" }, "input": { "projectColor": "Project color", @@ -993,6 +996,7 @@ "repeatAfter": "Set Repeating Interval", "percentDone": "Set Progress", "attachments": "Add Attachments", + "timeTracking": "Track time", "relatedTasks": "Add Relation", "moveProject": "Move", "duplicate": "Duplicate", @@ -1462,6 +1466,32 @@ "frontendVersion": "Frontend version: {version}", "apiVersion": "API version: {version}" }, + "timeTracking": { + "title": "Time tracking", + "stop": "Stop timer", + "logTime": "Log time", + "editEntry": "Edit entry", + "form": { + "task": "Task", + "taskSearch": "Search for a task…", + "commentPlaceholder": "What did you work on?", + "save": "Save entry", + "startTimer": "Start timer", + "update": "Update entry", + "smartFill": "Fill from last entry" + }, + "list": { + "emptyTask": "No time tracked for this task yet.", + "emptyFiltered": "No time tracked for the selected filters.", + "total": "Total", + "time": "Time", + "duration": "Duration" + }, + "browse": { + "selectRange": "Select a range", + "userSearch": "Search for a user…" + } + }, "time": { "units": { "seconds": "second|seconds", diff --git a/frontend/src/i18n/lang/es-ES.json b/frontend/src/i18n/lang/es-ES.json index 962beb5f2..98e9f78e6 100644 --- a/frontend/src/i18n/lang/es-ES.json +++ b/frontend/src/i18n/lang/es-ES.json @@ -251,8 +251,7 @@ "default": "Predeterminado", "month": "Mes", "day": "Día", - "hour": "Hora", - "range": "Rango de fechas" + "hour": "Hora" }, "table": { "title": "Tabla", @@ -261,7 +260,6 @@ "kanban": { "title": "Kanban", "limit": "Límite: {limit}", - "noLimit": "No Establecido", "doneBucket": "Contenedor completado", "doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.", "doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.", diff --git a/frontend/src/i18n/lang/fa-IR.json b/frontend/src/i18n/lang/fa-IR.json index 07198d732..fdc0681c7 100644 --- a/frontend/src/i18n/lang/fa-IR.json +++ b/frontend/src/i18n/lang/fa-IR.json @@ -463,7 +463,6 @@ "month": "ماه", "day": "روز", "hour": "ساعت", - "range": "محدوده تاریخ", "chartLabel": "نمودار گانت پروژه", "taskBarsForRow": "نوارهای وظیفه برای ردیف {rowId}", "taskBarLabel": "وظیفه: {task}. از {startDate} تا {endDate}. {dateType}. برای ویرایش کلیک کنید، برای جابجایی بکشید.", @@ -492,7 +491,6 @@ "kanban": { "title": "کانبان", "limit": "محدودیت: {limit}", - "noLimit": "تنظیم نشده", "doneBucket": "سطل انجام شده", "doneBucketHint": "تمام وظایفی که به این سطل منتقل شوند به طور خودکار به عنوان انجام شده علامت‌گذاری می‌شوند.", "doneBucketHintExtended": "تمام وظایفی که به سطل انجام شده منتقل شوند به طور خودکار علامت‌گذاری می‌شوند. همچنین تمام وظایفی که از جای دیگر به عنوان انجام شده علامت‌گذاری شوند نیز به اینجا منتقل خواهند شد.", diff --git a/frontend/src/i18n/lang/fi-FI.json b/frontend/src/i18n/lang/fi-FI.json index 5ceb0be11..90e1e99c6 100644 --- a/frontend/src/i18n/lang/fi-FI.json +++ b/frontend/src/i18n/lang/fi-FI.json @@ -347,8 +347,7 @@ "default": "Oletus", "month": "Kuukausi", "day": "Päivä", - "hour": "Tunti", - "range": "Ajanjakso" + "hour": "Tunti" }, "table": { "title": "Taulukko", @@ -357,7 +356,6 @@ "kanban": { "title": "Kanban", "limit": "Raja: {limit}", - "noLimit": "Ei Asetettu", "doneBucket": "Valmiit sarake", "doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.", "doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.", diff --git a/frontend/src/i18n/lang/fr-FR.json b/frontend/src/i18n/lang/fr-FR.json index 7bcb472c7..fe9357062 100644 --- a/frontend/src/i18n/lang/fr-FR.json +++ b/frontend/src/i18n/lang/fr-FR.json @@ -346,7 +346,6 @@ "month": "Mois", "day": "Jour", "hour": "Heure", - "range": "Intervalle", "chartLabel": "Diagramme de Gantt du projet", "taskBarsForRow": "Barres de tâches pour la ligne {rowId}", "taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.", @@ -370,7 +369,6 @@ "kanban": { "title": "Kanban", "limit": "Limite : {limit}", - "noLimit": "Non défini", "doneBucket": "Colonne des tâches terminées", "doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.", "doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.", diff --git a/frontend/src/i18n/lang/he-IL.json b/frontend/src/i18n/lang/he-IL.json index c8abc236a..0477ecffa 100644 --- a/frontend/src/i18n/lang/he-IL.json +++ b/frontend/src/i18n/lang/he-IL.json @@ -318,8 +318,7 @@ "default": "ברירת מחדל", "month": "חודש", "day": "יום", - "hour": "שעה", - "range": "טווח תאריכים" + "hour": "שעה" }, "table": { "title": "טבלה", @@ -328,7 +327,6 @@ "kanban": { "title": "קאנבאן", "limit": "הגבלה: {limit}", - "noLimit": "לא נקבע", "doneBucket": "דלי גמורים", "doneBucketHint": "דלי גמורים נשמר בהצלחה.", "doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.", diff --git a/frontend/src/i18n/lang/hr-HR.json b/frontend/src/i18n/lang/hr-HR.json index 3f5f32bf6..77797bbe8 100644 --- a/frontend/src/i18n/lang/hr-HR.json +++ b/frontend/src/i18n/lang/hr-HR.json @@ -289,16 +289,14 @@ "default": "Zadano", "month": "Mjesec", "day": "Dan", - "hour": "Sat", - "range": "Raspon datuma" + "hour": "Sat" }, "table": { "title": "Tablica", "columns": "Stupci" }, "kanban": { - "title": "Kanban", - "noLimit": "Nije postavljeno" + "title": "Kanban" }, "pseudo": { "favorites": { diff --git a/frontend/src/i18n/lang/hu-HU.json b/frontend/src/i18n/lang/hu-HU.json index 178c0bf4a..1cace9336 100644 --- a/frontend/src/i18n/lang/hu-HU.json +++ b/frontend/src/i18n/lang/hu-HU.json @@ -290,8 +290,7 @@ "default": "Alapértelmezett", "month": "Hónap", "day": "Nap", - "hour": "Óra", - "range": "Időintervallum" + "hour": "Óra" }, "table": { "title": "Táblázat", @@ -300,7 +299,6 @@ "kanban": { "title": "Kanban", "limit": "Korlát: {limit}", - "noLimit": "Nincs beállítva", "doneBucket": "Kész vödör", "doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.", "doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.", diff --git a/frontend/src/i18n/lang/it-IT.json b/frontend/src/i18n/lang/it-IT.json index ec402b64b..985be6830 100644 --- a/frontend/src/i18n/lang/it-IT.json +++ b/frontend/src/i18n/lang/it-IT.json @@ -362,7 +362,6 @@ "month": "Mese", "day": "Giorno", "hour": "Ora", - "range": "Intervallo di date", "chartLabel": "Progetto diagramma di Gantt", "taskBarsForRow": "Barre delle attività per riga {rowId}", "taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Non Impostato", "doneBucket": "Colonna attività completate", "doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.", "doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.", diff --git a/frontend/src/i18n/lang/ja-JP.json b/frontend/src/i18n/lang/ja-JP.json index 5e0d38cbd..2462d9f4e 100644 --- a/frontend/src/i18n/lang/ja-JP.json +++ b/frontend/src/i18n/lang/ja-JP.json @@ -470,7 +470,6 @@ "month": "月", "day": "日", "hour": "時間", - "range": "期間", "chartLabel": "プロジェクトガントチャート", "taskBarsForRow": "行 {rowId} のタスクバー", "taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。", @@ -499,7 +498,6 @@ "kanban": { "title": "カンバン", "limit": "上限: {limit}", - "noLimit": "未設定", "doneBucket": "バケットを完了", "doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。", "doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。", diff --git a/frontend/src/i18n/lang/ko-KR.json b/frontend/src/i18n/lang/ko-KR.json index f6136e700..3f541c33c 100644 --- a/frontend/src/i18n/lang/ko-KR.json +++ b/frontend/src/i18n/lang/ko-KR.json @@ -323,8 +323,7 @@ "default": "기본값", "month": "월", "day": "일", - "hour": "시", - "range": "날짜 범위" + "hour": "시" }, "table": { "title": "테이블", @@ -333,7 +332,6 @@ "kanban": { "title": "칸반", "limit": "제한: {limit}", - "noLimit": "설정 안함", "doneBucket": "완료 버킷", "doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.", "doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.", diff --git a/frontend/src/i18n/lang/lt-LT.json b/frontend/src/i18n/lang/lt-LT.json index 98bd7dbbe..a27b89c9c 100644 --- a/frontend/src/i18n/lang/lt-LT.json +++ b/frontend/src/i18n/lang/lt-LT.json @@ -320,8 +320,7 @@ "default": "Numatytasis", "month": "Mėnuo", "day": "Diena", - "hour": "Valanda", - "range": "Datos intervalas" + "hour": "Valanda" }, "table": { "title": "Lentelė", @@ -330,7 +329,6 @@ "kanban": { "title": "Kanbanas", "limit": "Limitas: {limit}", - "noLimit": "Nenustatytas", "doneBucket": "Atliktųjų telkinys", "doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.", "doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.", diff --git a/frontend/src/i18n/lang/nl-NL.json b/frontend/src/i18n/lang/nl-NL.json index 8b2d349db..a645a5d6d 100644 --- a/frontend/src/i18n/lang/nl-NL.json +++ b/frontend/src/i18n/lang/nl-NL.json @@ -470,7 +470,6 @@ "month": "Maand", "day": "Dag", "hour": "Uur", - "range": "Datumbereik", "chartLabel": "Project Gantt-diagram", "taskBarsForRow": "Taakbalken voor rij {rowId}", "taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.", @@ -499,7 +498,6 @@ "kanban": { "title": "Kanban", "limit": "Limiet: {limit}", - "noLimit": "Niet ingesteld", "doneBucket": "Categorie 'voltooid'", "doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.", "doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.", diff --git a/frontend/src/i18n/lang/no-NO.json b/frontend/src/i18n/lang/no-NO.json index a32189bd4..6761a82dd 100644 --- a/frontend/src/i18n/lang/no-NO.json +++ b/frontend/src/i18n/lang/no-NO.json @@ -353,7 +353,6 @@ "month": "Måned", "day": "Dag", "hour": "Time", - "range": "Datointervall", "chartLabel": "Gantt-kart for prosjekt", "taskBarsForRow": "Oppgavelinjer for rad {rowId}", "taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.", @@ -377,7 +376,6 @@ "kanban": { "title": "Kanban", "limit": "Begrens: {limit}", - "noLimit": "Ikke angitt", "doneBucket": "Ferdigkurv", "doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.", "doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.", diff --git a/frontend/src/i18n/lang/pl-PL.json b/frontend/src/i18n/lang/pl-PL.json index 796e5e37e..1e3590223 100644 --- a/frontend/src/i18n/lang/pl-PL.json +++ b/frontend/src/i18n/lang/pl-PL.json @@ -300,8 +300,7 @@ "default": "Domyślnie", "month": "Miesiąc", "day": "Dzień", - "hour": "Godzina", - "range": "Zakres dat" + "hour": "Godzina" }, "table": { "title": "Tabela", @@ -310,7 +309,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nie ustawiony", "doneBucket": "Zakończone zadania", "doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.", "doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.", diff --git a/frontend/src/i18n/lang/pt-BR.json b/frontend/src/i18n/lang/pt-BR.json index e5c91a043..9a2a0f815 100644 --- a/frontend/src/i18n/lang/pt-BR.json +++ b/frontend/src/i18n/lang/pt-BR.json @@ -286,8 +286,7 @@ "default": "Padrão", "month": "Mês", "day": "Dia", - "hour": "Hora", - "range": "Período" + "hour": "Hora" }, "table": { "title": "Tabela", @@ -296,7 +295,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Não definido", "doneBucket": "Bucket concluído", "doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.", "doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.", diff --git a/frontend/src/i18n/lang/pt-PT.json b/frontend/src/i18n/lang/pt-PT.json index 58c6e6aca..e986fe4ca 100644 --- a/frontend/src/i18n/lang/pt-PT.json +++ b/frontend/src/i18n/lang/pt-PT.json @@ -362,7 +362,6 @@ "month": "Mês", "day": "Dia", "hour": "Hora", - "range": "Intervalo de Datas", "chartLabel": "Gráfico de Gantt do projeto", "taskBarsForRow": "Barras de tarefas para a linha {rowId}", "taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Não Definido", "doneBucket": "Conjunto concluído", "doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.", "doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.", diff --git a/frontend/src/i18n/lang/ru-RU.json b/frontend/src/i18n/lang/ru-RU.json index 9f4163bbf..8559e34e8 100644 --- a/frontend/src/i18n/lang/ru-RU.json +++ b/frontend/src/i18n/lang/ru-RU.json @@ -407,7 +407,6 @@ "month": "Месяц", "day": "День", "hour": "Час", - "range": "Диапазон", "chartLabel": "Диаграмма Ганта", "taskBarsForRow": "Задачи в строке {rowId}", "taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.", @@ -435,7 +434,6 @@ "kanban": { "title": "Канбан", "limit": "Лимит: {limit}", - "noLimit": "не установлен", "doneBucket": "Колонка завершённых", "doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.", "doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.", diff --git a/frontend/src/i18n/lang/sl-SI.json b/frontend/src/i18n/lang/sl-SI.json index faaf474af..d00e67842 100644 --- a/frontend/src/i18n/lang/sl-SI.json +++ b/frontend/src/i18n/lang/sl-SI.json @@ -314,8 +314,7 @@ "default": "Privzeto", "month": "Mesec", "day": "Dan", - "hour": "Ura", - "range": "Datumski obseg" + "hour": "Ura" }, "table": { "title": "Tabela", @@ -324,7 +323,6 @@ "kanban": { "title": "Kanban", "limit": "Omejitev: {limit}", - "noLimit": "Ni nastavljeno", "doneBucket": "Vedro končanih nalog", "doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.", "doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.", diff --git a/frontend/src/i18n/lang/sv-SE.json b/frontend/src/i18n/lang/sv-SE.json index 0bbe11cd7..8d7f7e72d 100644 --- a/frontend/src/i18n/lang/sv-SE.json +++ b/frontend/src/i18n/lang/sv-SE.json @@ -362,7 +362,6 @@ "month": "Månad", "day": "Dag", "hour": "Timme", - "range": "Datumintervall", "chartLabel": "Projektets Gantt-schema", "taskBarsForRow": "Uppgiftsstaplar för rad {rowId}", "taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Gräns: {limit}", - "noLimit": "Ej inställt", "doneBucket": "Färdigkolumn", "doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.", "doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.", diff --git a/frontend/src/i18n/lang/tr-TR.json b/frontend/src/i18n/lang/tr-TR.json index 40a33632b..bcd0bd6f6 100644 --- a/frontend/src/i18n/lang/tr-TR.json +++ b/frontend/src/i18n/lang/tr-TR.json @@ -362,7 +362,6 @@ "month": "Ay", "day": "Gün", "hour": "Saat", - "range": "Tarih Aralığı", "chartLabel": "Proje Gantt Şeması", "taskBarsForRow": "{rowId} satırı için görev çubukları", "taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Sınır: {limit}", - "noLimit": "Belirlenmedi", "doneBucket": "Tamamlananlar kutusu", "doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.", "doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.", diff --git a/frontend/src/i18n/lang/uk-UA.json b/frontend/src/i18n/lang/uk-UA.json index 18b569459..bf5082ca0 100644 --- a/frontend/src/i18n/lang/uk-UA.json +++ b/frontend/src/i18n/lang/uk-UA.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "YYYY/MM/DD" }, "timeFormat": "Формат часу", + "timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу", "timeFormatOptions": { "12h": "12-годинний (AM/PM)", "24h": "24-годинний (HH:mm)" @@ -392,6 +393,7 @@ "title": "Дублювати цей проєкт", "label": "Дублювати", "text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:", + "shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту", "success": "Проєкт дубльовано." }, "edit": { @@ -470,7 +472,6 @@ "month": "Місяць", "day": "День", "hour": "Година", - "range": "Проміжок днів", "chartLabel": "Діаграма Ганта", "taskBarsForRow": "Смуги завдань для рядка {rowId}", "taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.", @@ -499,7 +500,6 @@ "kanban": { "title": "Дошка", "limit": "Межа: {limit}", - "noLimit": "Немає", "doneBucket": "Колонка «Виконано»", "doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.", "doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.", @@ -783,7 +783,10 @@ "closeDialog": "Закрити діалог", "closeQuickActions": "Закрити швидкі дії", "skipToContent": "Перейти до основного вмісту", - "sortBy": "Сортувати за" + "sortBy": "Сортувати за", + "dateRange": "Діапазон дат", + "notSet": "Не встановлено", + "user": "Користувач" }, "input": { "projectColor": "Колір проєкту", @@ -986,13 +989,14 @@ "assign": "Доручити", "label": "Позначки", "priority": "Встановити пріоритет", - "dueDate": "Встановити термін", + "dueDate": "Встановити термін виконання", "startDate": "Почати", "endDate": "Встановити дату завершення", "reminders": "Нагадування", "repeatAfter": "Повторювати", "percentDone": "Встановити прогрес", "attachments": "Вкласти", + "timeTracking": "Відстежити час", "relatedTasks": "Пов'язати", "moveProject": "Перемістити", "duplicate": "Дублювати", @@ -1148,6 +1152,7 @@ "repeat": { "everyDay": "Щодня", "everyWeek": "Щотижня", + "every30d": "Кожні 30 днів", "mode": "Спосіб", "monthly": "Щомісяця", "fromCurrentDate": "З дня закінчення", @@ -1461,6 +1466,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": "секунда|секунд(и)", diff --git a/frontend/src/i18n/lang/vi-VN.json b/frontend/src/i18n/lang/vi-VN.json index ad559767d..9f845a319 100644 --- a/frontend/src/i18n/lang/vi-VN.json +++ b/frontend/src/i18n/lang/vi-VN.json @@ -319,8 +319,7 @@ "default": "Mặc định", "month": "Tháng", "day": "Ngày", - "hour": "Giờ", - "range": "Khoảng thời gian" + "hour": "Giờ" }, "table": { "title": "Bảng", @@ -329,7 +328,6 @@ "kanban": { "title": "Kanban", "limit": "Giới hạn: {limit}", - "noLimit": "Không giới hạn", "doneBucket": "Cột hoàn thành", "doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.", "doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.", diff --git a/frontend/src/i18n/lang/zh-CN.json b/frontend/src/i18n/lang/zh-CN.json index 329eb42ff..8e7ce7888 100644 --- a/frontend/src/i18n/lang/zh-CN.json +++ b/frontend/src/i18n/lang/zh-CN.json @@ -338,7 +338,6 @@ "month": "月", "day": "日", "hour": "时", - "range": "日期范围", "chartLabel": "项目甘特图", "scheduledDates": "预定日期", "estimatedDates": "估计日期" @@ -350,7 +349,6 @@ "kanban": { "title": "看板", "limit": "限制: {limit}", - "noLimit": "未设置", "doneBucket": "已完成的桶数", "doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。", "doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。", diff --git a/frontend/src/i18n/lang/zh-TW.json b/frontend/src/i18n/lang/zh-TW.json index b55b8b4fb..594fb17c5 100644 --- a/frontend/src/i18n/lang/zh-TW.json +++ b/frontend/src/i18n/lang/zh-TW.json @@ -362,7 +362,6 @@ "month": "月", "day": "日", "hour": "時", - "range": "日期範圍", "chartLabel": "專案甘特圖", "taskBarsForRow": "第 {rowId} 列的任務列", "taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。", @@ -386,7 +385,6 @@ "kanban": { "title": "看板", "limit": "限制: {limit}", - "noLimit": "未設定", "doneBucket": "已完成類別", "doneBucketHint": "移入此類別的任務將自動標記為已完成。", "doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。", diff --git a/frontend/src/i18n/useDayjsLanguageSync.ts b/frontend/src/i18n/useDayjsLanguageSync.ts index 21e3fd7d6..792a284cd 100644 --- a/frontend/src/i18n/useDayjsLanguageSync.ts +++ b/frontend/src/i18n/useDayjsLanguageSync.ts @@ -22,6 +22,7 @@ export const DAYJS_LOCALE_MAPPING = { 'ja-jp': 'ja', 'hu-hu': 'hu', 'ar-sa': 'ar-sa', + 'fa-ir': 'fa', 'sl-si': 'sl', 'pt-br': 'pt', 'hr-hr': 'hr', @@ -55,6 +56,7 @@ export const DAYJS_LANGUAGE_IMPORTS = { 'ja-jp': () => import('dayjs/locale/ja'), 'hu-hu': () => import('dayjs/locale/hu'), 'ar-sa': () => import('dayjs/locale/ar-sa'), + 'fa-ir': () => import('dayjs/locale/fa'), 'sl-si': () => import('dayjs/locale/sl'), 'pt-br': () => import('dayjs/locale/pt-br'), 'hr-hr': () => import('dayjs/locale/hr'), diff --git a/frontend/src/modelTypes/IProjectDuplicate.ts b/frontend/src/modelTypes/IProjectDuplicate.ts index a24efa58c..cf9ba9167 100644 --- a/frontend/src/modelTypes/IProjectDuplicate.ts +++ b/frontend/src/modelTypes/IProjectDuplicate.ts @@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract { projectId: number duplicatedProject: IProject | null parentProjectId: IProject['id'] + duplicateShares: boolean } diff --git a/frontend/src/modelTypes/ITask.ts b/frontend/src/modelTypes/ITask.ts index f8504c30a..45b6de45a 100644 --- a/frontend/src/modelTypes/ITask.ts +++ b/frontend/src/modelTypes/ITask.ts @@ -51,6 +51,7 @@ export interface ITask extends IAbstract { reactions: IReactionPerEntity comments: ITaskComment[] commentCount?: number + timeEntriesCount?: number createdBy: IUser created: Date diff --git a/frontend/src/modelTypes/ITimeEntry.ts b/frontend/src/modelTypes/ITimeEntry.ts new file mode 100644 index 000000000..249ce2129 --- /dev/null +++ b/frontend/src/modelTypes/ITimeEntry.ts @@ -0,0 +1,16 @@ +import type {IAbstract} from './IAbstract' + +export interface ITimeEntry extends IAbstract { + id: number + userId: number + // Exactly one of taskId / projectId is set (0 means unset). + taskId: number + projectId: number + startTime: Date + // null while the live timer is running. + endTime: Date | null + comment: string + + created: Date + updated: Date +} diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 3cc6faa0d..ffd75e5ac 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -28,6 +28,7 @@ export interface IFrontendSettings { commentSortOrder: 'asc' | 'desc' desktopQuickEntryShortcut: string quickAddDefaultReminders: ITaskReminder[] + timeTrackingDefaultStart?: string } export interface IExtraSettingsLink { diff --git a/frontend/src/models/projectDuplicateModel.ts b/frontend/src/models/projectDuplicateModel.ts index ac137714d..53af125ba 100644 --- a/frontend/src/models/projectDuplicateModel.ts +++ b/frontend/src/models/projectDuplicateModel.ts @@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel) { super() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 69cb0cc36..02e5ad580 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,7 +6,9 @@ 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' import {useAuthStore} from '@/stores/auth' import {useBaseStore} from '@/stores/base' @@ -29,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} } @@ -433,6 +435,15 @@ const router = createRouter({ name: 'about', component: () => import('@/views/About.vue'), }, + { + path: '/time-tracking', + name: 'time-tracking', + component: () => import('@/views/time-tracking/TimeTracking.vue'), + meta: { + requiresTimeTracking: true, + title: 'timeTracking.title', + }, + }, { path: '/admin', component: () => import('@/views/admin/AdminShell.vue'), @@ -462,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= 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 @@ -489,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'} } @@ -519,7 +562,7 @@ router.beforeEach(async (to, from) => { const baseStore = useBaseStore() await baseStore.appReady const configStore = useConfigStore() - const featureOn = configStore.isProFeatureEnabled('admin_panel') + const featureOn = configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL) // isAdmin comes from /user, not the JWT; force-fetch in case checkAuth() was debounced. if (authStore.info?.isAdmin === undefined) { await authStore.refreshUserInfo() @@ -530,6 +573,15 @@ router.beforeEach(async (to, from) => { } } + if (to.meta?.requiresTimeTracking) { + const baseStore = useBaseStore() + await baseStore.appReady + const configStore = useConfigStore() + if (!configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) { + return {name: 'not-found'} + } + } + if(from.hash && from.hash.startsWith(LINK_SHARE_HASH_PREFIX)) { to.hash = from.hash } @@ -546,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 } diff --git a/frontend/src/services/timeEntry.test.ts b/frontend/src/services/timeEntry.test.ts new file mode 100644 index 000000000..faa68bcc8 --- /dev/null +++ b/frontend/src/services/timeEntry.test.ts @@ -0,0 +1,33 @@ +import {describe, it, expect} from 'vitest' + +import {parseTimeEntry} from './timeEntry' + +describe('parseTimeEntry', () => { + it('maps snake_case keys and coerces dates', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 2, + task_id: 3, + project_id: 0, + start_time: '2020-01-01T09:00:00Z', + end_time: '2020-01-01T10:00:00Z', + comment: 'work', + }) + expect(e.userId).toBe(2) + expect(e.taskId).toBe(3) + expect(e.comment).toBe('work') + expect(e.startTime).toBeInstanceOf(Date) + expect(e.endTime).toBeInstanceOf(Date) + }) + + it('treats a null end time as a running timer', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 1, + task_id: 1, + start_time: '2020-01-01T09:00:00Z', + end_time: null, + }) + expect(e.endTime).toBeNull() + }) +}) diff --git a/frontend/src/services/timeEntry.ts b/frontend/src/services/timeEntry.ts new file mode 100644 index 000000000..b76a3d11a --- /dev/null +++ b/frontend/src/services/timeEntry.ts @@ -0,0 +1,91 @@ +import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher' +import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +// Time tracking is the first frontend feature on /api/v2, while the shared +// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2 +// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded +// into the proper service layer once the frontend moves fully onto v2. +function v2Url(path: string): string { + const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/') + return new URL(v2Base + path, window.location.origin).toString() +} + +export function parseTimeEntry(raw: Record): ITimeEntry { + const e = objectToCamelCase(raw) + const end = e.endTime as string | null | undefined + return { + id: e.id, + userId: e.userId, + taskId: e.taskId ?? 0, + projectId: e.projectId ?? 0, + startTime: new Date(e.startTime), + // null end_time = a running timer. + endTime: end ? new Date(end) : null, + comment: e.comment ?? '', + created: new Date(e.created), + updated: new Date(e.updated), + maxPermission: e.maxPermission ?? null, + } +} + +export interface TimeEntryListParams { + filter?: string + filterTimezone?: string + q?: string + page?: number + perPage?: number +} + +export interface TimeEntryListResult { + items: ITimeEntry[] + total: number + page: number + perPage: number + totalPages: number +} + +export function useTimeEntryService() { + const http = AuthenticatedHTTPFactory() + + async function getAll(params: TimeEntryListParams = {}): Promise { + const {data} = await http.get(v2Url('time-entries'), { + params: { + filter: params.filter, + filter_timezone: params.filterTimezone, + q: params.q, + page: params.page, + per_page: params.perPage, + }, + }) + return { + items: (data.items ?? []).map(parseTimeEntry), + total: data.total, + page: data.page, + perPage: data.per_page, + totalPages: data.total_pages, + } + } + + async function create(entry: Partial): Promise { + const {data} = await http.post(v2Url('time-entries'), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function update(entry: Partial & {id: number}): Promise { + const {data} = await http.put(v2Url(`time-entries/${entry.id}`), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function remove(id: number): Promise { + await http.delete(v2Url(`time-entries/${id}`)) + } + + async function stopTimer(): Promise { + const {data} = await http.post(v2Url('time-entries/timer/stop')) + return parseTimeEntry(data) + } + + return {getAll, create, update, remove, stopTimer} +} diff --git a/frontend/src/stores/auth.renewToken.test.ts b/frontend/src/stores/auth.renewToken.test.ts new file mode 100644 index 000000000..331f86856 --- /dev/null +++ b/frontend/src/stores/auth.renewToken.test.ts @@ -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) { + 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) + }) +}) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e2576760f..7a444acc7 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -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 { + 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) diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 73160acd5..89b57bcf1 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -7,6 +7,7 @@ import {objectToCamelCase} from '@/helpers/case' import type {IProvider} from '@/types/IProvider' import type {MIGRATORS} from '@/views/migrate/migrators' +import type {ProFeature} from '@/constants/proFeatures' import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl' export interface ConfigState { @@ -46,6 +47,7 @@ export interface ConfigState { publicTeamsEnabled: boolean, allowIconChanges: boolean, enabledProFeatures: string[], + concurrentWrites: boolean, } export const useConfigStore = defineStore('config', () => { @@ -87,6 +89,7 @@ export const useConfigStore = defineStore('config', () => { publicTeamsEnabled: false, allowIconChanges: true, enabledProFeatures: [], + concurrentWrites: false, }) const migratorsEnabled = computed(() => state.availableMigrators?.length > 0) @@ -104,7 +107,7 @@ export const useConfigStore = defineStore('config', () => { Object.assign(state, config) } - function isProFeatureEnabled(name: string): boolean { + function isProFeatureEnabled(name: ProFeature): boolean { return state.enabledProFeatures?.includes(name) ?? false } diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 37110100e..18a3dfad2 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter) { 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) diff --git a/frontend/src/stores/tasks.test.ts b/frontend/src/stores/tasks.test.ts index 4d7a60af6..644d6e6bb 100644 --- a/frontend/src/stores/tasks.test.ts +++ b/frontend/src/stores/tasks.test.ts @@ -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) + }) +}) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 3eb99d1ef..ac27ff0d2 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -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( + items: readonly T[], + write: (item: T) => Promise, + concurrent: boolean, +): Promise { + 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 } diff --git a/frontend/src/stores/timeTracking.test.ts b/frontend/src/stores/timeTracking.test.ts new file mode 100644 index 000000000..62d3c7d09 --- /dev/null +++ b/frontend/src/stores/timeTracking.test.ts @@ -0,0 +1,139 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest' +import {setActivePinia, createPinia} from 'pinia' + +import {useTimeTrackingStore} from './timeTracking' +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +const {getAllMock, removeMock, authInfo} = vi.hoisted(() => ({ + getAllMock: vi.fn(), + removeMock: vi.fn(), + authInfo: {value: {id: 7} as {id: number} | null}, +})) + +vi.mock('@/services/timeEntry', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useTimeEntryService: () => ({ + getAll: getAllMock, + remove: removeMock, + }), + } +}) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + info: authInfo.value, + }), +})) + +function entry(id: number, endTime: Date | null): ITimeEntry { + return { + id, + userId: 1, + taskId: 1, + projectId: 0, + startTime: new Date(), + endTime, + comment: '', + created: new Date(), + updated: new Date(), + maxPermission: null, + } +} + +describe('timeTracking store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getAllMock.mockReset() + removeMock.mockReset() + authInfo.value = {id: 7} + }) + + it('a running entry becomes the active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + expect(store.activeTimer?.id).toBe(4) + expect(store.hasActiveTimer).toBe(true) + }) + + it('a stopped entry clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(4, new Date())) + expect(store.activeTimer).toBeNull() + }) + + it('a stop for a different timer leaves the active one alone', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(5, new Date())) + expect(store.activeTimer?.id).toBe(4) + }) + + it('patches a stopped entry in the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, null)] + const stopped = entry(4, new Date('2026-01-01T10:00:00Z')) + store.applyTimerEvent(stopped) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 4)?.endTime).toEqual(stopped.endTime) + expect(store.browsedEntries).toHaveLength(2) + }) + + it('does not insert an unknown entry into the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null)] + store.applyTimerEvent(entry(9, new Date())) + expect(store.browsedEntries).toHaveLength(1) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 9)).toBeUndefined() + }) + + it('hydrates the active timer scoped to the current user', async () => { + getAllMock.mockResolvedValue({items: [entry(4, null)]}) + + const store = useTimeTrackingStore() + await store.hydrateActiveTimer() + + expect(getAllMock).toHaveBeenCalledWith({ + filter: 'user_id = 7 && end_time = null', + perPage: 1, + }) + expect(store.activeTimer?.id).toBe(4) + }) + + it('clears the active timer when deleting the running entry', async () => { + removeMock.mockResolvedValue(undefined) + + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + await store.removeEntry(4) + + expect(removeMock).toHaveBeenCalledWith(4) + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion drops the entry and clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(4) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion of another entry leaves the active timer alone', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(5) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([4]) + expect(store.activeTimer?.id).toBe(4) + }) +}) diff --git a/frontend/src/stores/timeTracking.ts b/frontend/src/stores/timeTracking.ts new file mode 100644 index 000000000..b384c0538 --- /dev/null +++ b/frontend/src/stores/timeTracking.ts @@ -0,0 +1,142 @@ +import {ref, computed} from 'vue' +import {acceptHMRUpdate, defineStore} from 'pinia' + +import {useWebSocket} from '@/composables/useWebSocket' +import {useTimeEntryService, parseTimeEntry} from '@/services/timeEntry' +import {useAuthStore} from '@/stores/auth' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +export const useTimeTrackingStore = defineStore('timeTracking', () => { + const activeTimer = ref(null) + const browsedEntries = ref([]) + + const hasActiveTimer = computed(() => activeTimer.value !== null) + + async function browseEntries(filter: string) { + const {items} = await useTimeEntryService().getAll({ + filter, + filterTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + perPage: 250, + }) + browsedEntries.value = items + } + + // Drop a deleted entry from the list and clear the active timer if it was it. + // Shared by the local delete and the cross-tab WebSocket "timer.deleted". + function applyTimerDeletion(id: number) { + browsedEntries.value = browsedEntries.value.filter(entry => entry.id !== id) + if (activeTimer.value?.id === id) { + activeTimer.value = null + } + } + + async function removeEntry(id: number) { + await useTimeEntryService().remove(id) + applyTimerDeletion(id) + } + + // Replace an already-loaded entry in place so a stop (or any update) is + // reflected without a refetch. Never inserts — an event for an entry that + // isn't in the current filter shouldn't appear in the list. + function patchInList(entry: ITimeEntry) { + const index = browsedEntries.value.findIndex(existing => existing.id === entry.id) + if (index !== -1) { + browsedEntries.value.splice(index, 1, entry) + } + } + + // Reconcile the active timer from a timer event (WebSocket) or a local + // action: an entry with an end time is a stop — clear it if it's the one we + // track; otherwise it is the running timer. + function applyTimerEvent(entry: ITimeEntry) { + patchInList(entry) + if (entry.endTime !== null) { + if (activeTimer.value?.id === entry.id) { + activeTimer.value = null + } + return + } + activeTimer.value = entry + } + + // Source of truth on (re)connect: the caller's own running timer, if any. + async function hydrateActiveTimer() { + const userId = useAuthStore().info?.id + if (userId === undefined) { + activeTimer.value = null + return + } + + const {items} = await useTimeEntryService().getAll({ + filter: `user_id = ${userId} && end_time = null`, + perPage: 1, + }) + activeTimer.value = items[0] ?? null + } + + // Create any entry (manual, with an end time, or a running timer when end is + // omitted) and reconcile the active timer from the result. + async function createEntry(payload: Partial) { + const entry = await useTimeEntryService().create(payload) + applyTimerEvent(entry) + return entry + } + + async function updateEntry(payload: Partial & {id: number}) { + const entry = await useTimeEntryService().update(payload) + applyTimerEvent(entry) + return entry + } + + async function stopTimer() { + const entry = await useTimeEntryService().stopTimer() + applyTimerEvent(entry) + return entry + } + + let unsubscribers: Array<() => void> = [] + function subscribeToTimerEvents() { + const {subscribe} = useWebSocket() + // Ignore messages without a payload (e.g. subscribe acknowledgements). + const onEvent = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerEvent(parseTimeEntry(msg.data as Record)) + } + const onDelete = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerDeletion(parseTimeEntry(msg.data as Record).id) + } + unsubscribers.push(subscribe('timer.created', onEvent)) + unsubscribers.push(subscribe('timer.updated', onEvent)) + unsubscribers.push(subscribe('timer.deleted', onDelete)) + } + function unsubscribeFromTimerEvents() { + unsubscribers.forEach(unsubscribe => unsubscribe()) + unsubscribers = [] + } + + return { + activeTimer, + browsedEntries, + hasActiveTimer, + applyTimerEvent, + applyTimerDeletion, + hydrateActiveTimer, + browseEntries, + createEntry, + updateEntry, + stopTimer, + removeEntry, + subscribeToTimerEvents, + unsubscribeFromTimerEvents, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useTimeTrackingStore, import.meta.hot)) +} diff --git a/frontend/src/styles/theme/helpers.scss b/frontend/src/styles/theme/helpers.scss index b2238a00b..5e8685da2 100644 --- a/frontend/src/styles/theme/helpers.scss +++ b/frontend/src/styles/theme/helpers.scss @@ -4,6 +4,10 @@ } } -.is-pulled-right { +.is-pulled-end { float: right !important; } + +[dir="rtl"] .is-pulled-end { + float: left !important; +} diff --git a/frontend/src/views/labels/ListLabels.vue b/frontend/src/views/labels/ListLabels.vue index 30ae58645..b86dedd1d 100644 --- a/frontend/src/views/labels/ListLabels.vue +++ b/frontend/src/views/labels/ListLabels.vue @@ -5,7 +5,7 @@ > {{ $t('label.create.header') }} diff --git a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue index 764a44803..ebfc61003 100644 --- a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue +++ b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue @@ -8,6 +8,12 @@ >

{{ $t('project.duplicate.text') }}

+ + {{ $t('project.duplicate.shares') }} + @@ -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(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 diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 055e4f1ea..5396bc797 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -366,6 +366,15 @@ /> + +
+ +
+
{{ $t('task.detail.dateAndTime') }} + + {{ $t('task.detail.actions.timeTracking') }} + + configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) const kanbanStore = useKanbanStore() const authStore = useAuthStore() const baseStore = useBaseStore() @@ -923,7 +947,12 @@ watch( } try { - const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']}) + const expand = ['reactions', 'comments', 'is_unread', 'buckets'] + if (timeTrackingEnabled.value) { + // Only request the (server-computed) count when the feature is on. + expand.push('time_entries_count') + } + const loaded = await taskService.get({id}, {expand}) Object.assign(task.value, loaded) taskColor.value = task.value.hexColor setActiveFields() @@ -967,6 +996,7 @@ type FieldType = | 'reminders' | 'repeatAfter' | 'startDate' + | 'timeTracking' const activeFields: { [type in FieldType]: boolean } = reactive({ assignees: false, @@ -982,6 +1012,7 @@ const activeFields: { [type in FieldType]: boolean } = reactive({ reminders: false, repeatAfter: false, startDate: false, + timeTracking: false, }) function setActiveFields() { @@ -992,6 +1023,7 @@ function setActiveFields() { // Set all active fields based on values in the model activeFields.assignees = task.value.assignees.length > 0 activeFields.attachments = task.value.attachments.length > 0 + activeFields.timeTracking = (task.value.timeEntriesCount ?? 0) > 0 activeFields.dueDate = task.value.dueDate !== null activeFields.endDate = task.value.endDate !== null activeFields.labels = task.value.labels.length > 0 diff --git a/frontend/src/views/teams/ListTeams.vue b/frontend/src/views/teams/ListTeams.vue index 5b5d8077d..00c31703a 100644 --- a/frontend/src/views/teams/ListTeams.vue +++ b/frontend/src/views/teams/ListTeams.vue @@ -5,7 +5,7 @@ > {{ $t('team.create.title') }} diff --git a/frontend/src/views/time-tracking/TimeTracking.vue b/frontend/src/views/time-tracking/TimeTracking.vue new file mode 100644 index 000000000..b785fe9c3 --- /dev/null +++ b/frontend/src/views/time-tracking/TimeTracking.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/frontend/src/views/user/OpenIdAuth.vue b/frontend/src/views/user/OpenIdAuth.vue index b163ba4e5..3d41c4f0f 100644 --- a/frontend/src/views/user/OpenIdAuth.vue +++ b/frontend/src/views/user/OpenIdAuth.vue @@ -90,25 +90,11 @@ function findProvider(providerKey: string): IProvider | undefined { } async function authenticateWithCode() { - // This component gets mounted twice: The first time when the actual auth request hits the frontend, - // the second time after that auth request succeeded and the outer component "content-no-auth" isn't used - // but instead the "content-auth" component is used. Because this component is just a route and thus - // gets mounted as part of a which both the content-auth and content-no-auth components have, - // this re-mounts the component, even if the user is already authenticated. - // To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage - // which ensures only one auth request is done at a time. We don't simply check if the user is already - // authenticated to not prevent the whole authentication if some user is already logged in. - if (localStorage.getItem('authenticating')) { - return - } - localStorage.setItem('authenticating', 'true') - errorMessage.value = '' const providerKey = route.params.provider as string if (typeof route.query.error !== 'undefined') { - localStorage.removeItem('authenticating') sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = typeof route.query.message !== 'undefined' ? route.query.message as string @@ -118,7 +104,6 @@ async function authenticateWithCode() { const state = localStorage.getItem('state') if (typeof route.query.state === 'undefined' || route.query.state !== state) { - localStorage.removeItem('authenticating') sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = t('user.auth.openIdStateError') return @@ -145,8 +130,6 @@ async function authenticateWithCode() { return } errorMessage.value = getErrorText(e) - } finally { - localStorage.removeItem('authenticating') } } diff --git a/frontend/src/views/user/settings/General.vue b/frontend/src/views/user/settings/General.vue index 1d80c46ac..85506797b 100644 --- a/frontend/src/views/user/settings/General.vue +++ b/frontend/src/views/user/settings/General.vue @@ -151,6 +151,16 @@ :options="timeFormatOptions" /> + + +
@@ -306,12 +316,14 @@ import {useTitle} from '@/composables/useTitle' import {useProjectStore} from '@/stores/projects' import {useAuthStore} from '@/stores/auth' +import {useConfigStore} from '@/stores/config' import type {IUserSettings} from '@/modelTypes/IUserSettings' import {isSavedFilter} from '@/services/savedFilter' import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView' import {PRIORITIES} from '@/constants/priorities' import {DATE_DISPLAY} from '@/constants/dateDisplay' import {TIME_FORMAT} from '@/constants/timeFormat' +import {PRO_FEATURE} from '@/constants/proFeatures' import {RELATION_KINDS} from '@/types/IRelationKind' import {isDesktopApp} from '@/helpers/desktopAuth' import ShortcutRecorder from '@/components/misc/ShortcutRecorder.vue' @@ -396,6 +408,8 @@ const languageOptions = computed(() => ) const authStore = useAuthStore() +const configStore = useConfigStore() +const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) const settings = ref({ ...authStore.settings, @@ -416,6 +430,7 @@ const settings = ref({ defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related', // Clone to escape the store's readonly array type. quickAddDefaultReminders: [...(authStore.settings.frontendSettings.quickAddDefaultReminders ?? [])], + timeTrackingDefaultStart: authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00', }, }) diff --git a/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts b/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts new file mode 100644 index 000000000..26e15ae0f --- /dev/null +++ b/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts @@ -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 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 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 : pressing Escape while + // it is open must cancel only the prompt and leave the task dialog open, + // instead of falling through to the native '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() + }) +}) diff --git a/frontend/tests/e2e/project/sort-persistence.spec.ts b/frontend/tests/e2e/project/sort-persistence.spec.ts new file mode 100644 index 000000000..4aaeed3dc --- /dev/null +++ b/frontend/tests/e2e/project/sort-persistence.spec.ts @@ -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/) + }) +}) diff --git a/frontend/tests/e2e/time-tracking/time-tracking.spec.ts b/frontend/tests/e2e/time-tracking/time-tracking.spec.ts new file mode 100644 index 000000000..43831b87f --- /dev/null +++ b/frontend/tests/e2e/time-tracking/time-tracking.spec.ts @@ -0,0 +1,323 @@ +import {test, expect} from '../../support/fixtures' +import type {Page, Locator} from '@playwright/test' + +import {ProjectFactory} from '../../factories/project' +import {TaskFactory} from '../../factories/task' +import {TimeEntryFactory} from '../../factories/time_entry' +import {LicenseFactory} from '../../factories/license' +import {UserFactory} from '../../factories/user' +import {UserProjectFactory} from '../../factories/users_project' + +// Pick a project in the form's project picker. Waits for the project store to +// hydrate (the sidebar shows it) before searching so the result is there. +async function selectProject(page: Page, form: Locator, title: string) { + await expect(page.locator('.menu-container').getByText(title)).toBeVisible() + const input = form.locator('.multiselect').first().locator('input') + await input.click() + // pressSequentially (not fill) so the multiselect's @keyup search fires. + await input.pressSequentially(title, {delay: 30}) + await form.locator('.search-result-button').filter({hasText: title}).first().click() +} + +// Pick a task in the form's task picker (the second multiselect, after project). +async function selectTask(form: Locator, title: string) { + const input = form.locator('.multiselect').nth(1).locator('input') + await input.click() + await input.pressSequentially(title, {delay: 30}) + await form.locator('.search-result-button').filter({hasText: title}).first().click() +} + +// Open the time-tracking section on a task detail page. +async function openTaskTimeTracking(page: Page, taskId: number): Promise { + await page.goto(`/tasks/${taskId}`) + await page.locator('[data-cy="taskTrackTimeAction"]').click() + const section = page.locator('.task-time-tracking') + await expect(section).toBeVisible() + return section +} + +test.describe('Time tracking', () => { + test.describe('with the feature licensed', () => { + test.beforeEach(async () => { + await LicenseFactory.enable(['time_tracking']) + }) + + test.afterEach(async () => { + await LicenseFactory.disable() + }) + + test('shows the page and the sidebar entry', async ({authenticatedPage: page}) => { + await page.goto('/') + await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toBeVisible() + + await page.goto('/time-tracking') + await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible() + }) + + test('logs a manual time entry', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {title: 'E2E tracked project'}, false) + + await page.goto('/time-tracking') + await page.locator('[data-cy="addTimeEntry"]').click() + + const form = page.locator('[data-cy="timeEntryForm"]') + await expect(form).toBeVisible() + + await selectProject(page, form, 'E2E tracked project') + // Smart-fill populates both from and to, so the entry is complete. + await form.locator('[data-cy="smartFill"]').click() + await form.locator('[data-cy="saveTimeEntry"]').click() + + await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'E2E tracked project'})).toBeVisible() + }) + + test('saving with an empty To logs a completed entry, not a running timer', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {title: 'E2E save project'}, false) + + await page.goto('/time-tracking') + await page.locator('[data-cy="addTimeEntry"]').click() + const form = page.locator('[data-cy="timeEntryForm"]') + await selectProject(page, form, 'E2E save project') + // No smart-fill: leave "To" empty, then Save. + await form.locator('[data-cy="saveTimeEntry"]').click() + + // The entry is completed (no open-ended "…") and no timer started. + const entries = page.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(1) + await expect(entries.first()).not.toContainText('…') + await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible() + }) + + test('switching from a task to a project logs against the project', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {id: 1, title: 'XOR project'}, false) + await TaskFactory.create(1, {id: 1, title: 'XOR task', project_id: 1}, false) + + await page.goto('/time-tracking') + await page.locator('[data-cy="addTimeEntry"]').click() + const form = page.locator('[data-cy="timeEntryForm"]') + + // Pick a task, then change your mind to a project — the task must be cleared. + await selectTask(form, 'XOR task') + await selectProject(page, form, 'XOR project') + + await form.locator('[data-cy="smartFill"]').click() + await form.locator('[data-cy="saveTimeEntry"]').click() + + const entry = page.locator('[data-cy="timeEntry"]').first() + await expect(entry).toContainText('XOR project') + await expect(entry).not.toContainText('XOR task') + }) + + test('starts a timer and stopping it updates the same entry in the list', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {title: 'E2E timer project'}, false) + + await page.goto('/time-tracking') + await page.locator('[data-cy="addTimeEntry"]').click() + + const form = page.locator('[data-cy="timeEntryForm"]') + await selectProject(page, form, 'E2E timer project') + await form.locator('[data-cy="startTimer"]').click() + + const badge = page.locator('[data-cy="timerBadge"]') + await expect(badge).toBeVisible() + + // The running entry is in the list with an open-ended time range. + const entries = page.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(1) + await expect(entries.first()).toContainText('…') + + await badge.locator('[data-cy="stopTimer"]').click() + await expect(badge).not.toBeVisible() + + // The same entry is updated in place — end time set, no longer open-ended. + await expect(entries).toHaveCount(1) + await expect(entries.first()).not.toContainText('…') + }) + + test('does not show another user\'s readable running timer in the header', async ({ + authenticatedPage: page, + currentUser, + }) => { + const [timerOwner] = await UserFactory.create(1, {id: currentUser.id + 100}, false) + const [sharedProject] = await ProjectFactory.create(1, { + id: 1001, + title: 'Shared active timer project', + owner_id: timerOwner.id, + }, false) + await UserProjectFactory.create(1, { + project_id: sharedProject.id, + user_id: currentUser.id, + permission: 0, + }, false) + await TimeEntryFactory.create(1, { + project_id: sharedProject.id, + user_id: timerOwner.id, + end_time: null, + comment: 'other user running timer', + }, false) + + const activeTimerHydrated = page.waitForResponse(response => + response.request().method() === 'GET' && + response.url().includes('/api/v2/time-entries') && + response.url().includes('per_page=1'), + ) + await page.goto('/time-tracking') + await activeTimerHydrated + + await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'other user running timer'})).toBeVisible() + await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible() + }) + + test('hides edit/delete on entries owned by another user', async ({authenticatedPage: page, currentUser}) => { + const [other] = await UserFactory.create(1, {id: currentUser.id + 100}, false) + const [shared] = await ProjectFactory.create(1, {id: 2001, title: 'Shared log project', owner_id: other.id}, false) + await UserProjectFactory.create(1, {project_id: shared.id, user_id: currentUser.id, permission: 0}, false) + await TimeEntryFactory.create(1, {id: 10, project_id: shared.id, user_id: other.id, comment: 'theirs'}, false) + await TimeEntryFactory.create(1, {id: 11, project_id: shared.id, user_id: currentUser.id, comment: 'mine'}, false) + + await page.goto('/time-tracking') + const theirs = page.locator('[data-cy="timeEntry"]').filter({hasText: 'theirs'}) + const mine = page.locator('[data-cy="timeEntry"]').filter({hasText: 'mine'}) + await expect(theirs).toBeVisible() + await expect(mine).toBeVisible() + + // The current user keeps the controls on their own entry, but not the other's. + await expect(mine.locator('[data-cy="editTimeEntry"]')).toBeVisible() + await expect(theirs.locator('[data-cy="editTimeEntry"]')).toHaveCount(0) + await expect(theirs.locator('[data-cy="deleteTimeEntry"]')).toHaveCount(0) + }) + + test('task detail: logs an entry and toggles the form with the + button', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {title: 'P'}, false) + await TaskFactory.create(1, {title: 'Tracked task', project_id: 1}, false) + + const section = await openTaskTimeTracking(page, 1) + const form = section.locator('[data-cy="timeEntryForm"]') + + // No entries yet → the form is shown implicitly. + await expect(form).toBeVisible() + + await form.locator('[data-cy="smartFill"]').click() + await form.locator('[data-cy="saveTimeEntry"]').click() + await expect(section.locator('[data-cy="timeEntry"]')).toHaveCount(1) + + // With an entry, the form collapses behind the + button. + await expect(form).not.toBeVisible() + const addButton = section.locator('[data-cy="addTaskTimeEntry"]') + await expect(addButton).toBeVisible() + await addButton.click() + await expect(form).toBeVisible() + }) + + test('task detail: stopping a timer updates the entry in the list', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {title: 'P'}, false) + await TaskFactory.create(1, {title: 'Timed task', project_id: 1}, false) + + const section = await openTaskTimeTracking(page, 1) + await section.locator('[data-cy="timeEntryForm"] [data-cy="startTimer"]').click() + + const badge = page.locator('[data-cy="timerBadge"]') + await expect(badge).toBeVisible() + + const entries = section.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(1) + await expect(entries.first()).toContainText('…') + + await badge.locator('[data-cy="stopTimer"]').click() + await expect(badge).not.toBeVisible() + + await expect(entries).toHaveCount(1) + await expect(entries.first()).not.toContainText('…') + }) + + test('edits an entry from the list', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {id: 1, title: 'Edit project'}, false) + await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'original comment'}, false) + + await page.goto('/time-tracking') + const entries = page.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(1) + await expect(entries.first()).toContainText('original comment') + + await entries.first().locator('[data-cy="editTimeEntry"]').click() + const form = page.locator('[data-cy="timeEntryForm"]') + const comment = form.locator('[data-cy="timeEntryComment"]') + await expect(comment).toHaveValue('original comment') + await comment.fill('edited comment') + await form.locator('[data-cy="updateTimeEntry"]').click() + + await expect(entries).toHaveCount(1) + await expect(entries.first()).toContainText('edited comment') + await expect(entries.first()).not.toContainText('original comment') + }) + + test('deletes an entry from the list', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {id: 1, title: 'Delete project'}, false) + await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'to be deleted'}, false) + + await page.goto('/time-tracking') + const entries = page.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(1) + + await entries.first().locator('[data-cy="deleteTimeEntry"]').click() + await expect(entries).toHaveCount(0) + }) + + test('filters by project, reflected in the url and restored on reload', async ({authenticatedPage: page}) => { + await ProjectFactory.create(1, {id: 1, title: 'Alpha'}, false) + await ProjectFactory.create(1, {id: 2, title: 'Beta'}, false) + await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'alpha entry'}, false) + await TimeEntryFactory.create(1, {id: 2, project_id: 2, comment: 'beta entry'}, false) + + await page.goto('/time-tracking') + const entries = page.locator('[data-cy="timeEntry"]') + await expect(entries).toHaveCount(2) + + // Narrow to project Alpha in the filter modal. + await page.locator('[data-cy="openTimeTrackingFilters"]').click() + const dialog = page.locator('dialog[open]') + const projectInput = dialog.locator('.multiselect').first().locator('input') + await projectInput.click() + await projectInput.pressSequentially('Alpha', {delay: 30}) + await dialog.locator('.search-result-button').filter({hasText: 'Alpha'}).first().click() + + // The filter is written to the url. + await expect(page).toHaveURL(/[?&]project=1\b/) + + // ...and survives a reload (restored from the url): only Alpha's entry. + await page.reload() + await expect(entries).toHaveCount(1) + await expect(entries.first()).toContainText('Alpha') + await expect(page).toHaveURL(/[?&]project=1\b/) + }) + + test('clearing the date range does not crash the page', async ({authenticatedPage: page}) => { + await page.goto('/time-tracking') + // The default range surfaces as "Today" in the toolbar label. + await expect(page.locator('.time-tracking__range')).toHaveText('Today') + + await page.locator('[data-cy="openTimeTrackingFilters"]').click() + // Open the range popup (its trigger is the first button in the picker) and clear via Custom. + await page.locator('dialog[open] .datepicker-with-range-container').getByRole('button').first().click() + await page.getByRole('button', {name: 'Custom', exact: true}).click() + + // rangeLabel must not call getFullYear on a null date — the page stays alive. + await expect(page.locator('.time-tracking__range')).toHaveText('Select a range') + await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible() + }) + }) + + test.describe('without the feature licensed', () => { + test.beforeEach(async () => { + await LicenseFactory.disable() + }) + + test('hides the sidebar entry and blocks the route', async ({authenticatedPage: page}) => { + await page.goto('/') + await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toHaveCount(0) + + await page.goto('/time-tracking') + await expect(page.locator('[data-cy="addTimeEntry"]')).not.toBeVisible() + }) + }) +}) diff --git a/frontend/tests/e2e/user/oauth-authorize.spec.ts b/frontend/tests/e2e/user/oauth-authorize.spec.ts index 908e9ae3f..572802e46 100644 --- a/frontend/tests/e2e/user/oauth-authorize.spec.ts +++ b/frontend/tests/e2e/user/oauth-authorize.spec.ts @@ -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= 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) + }) }) diff --git a/frontend/tests/factories/time_entry.ts b/frontend/tests/factories/time_entry.ts new file mode 100644 index 000000000..0125d2917 --- /dev/null +++ b/frontend/tests/factories/time_entry.ts @@ -0,0 +1,31 @@ +import {Factory} from '../support/factory' + +// Local "YYYY-MM-DD HH:MM:SS" (the format the DB fixtures use), not ISO-with-Z. +// start_time is filtered with datemath day windows that resolve to local time, +// and the comparison is lexical — a UTC-stamped value falls outside "today" +// near midnight. +function sqlDateTime(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +export class TimeEntryFactory extends Factory { + static table = 'time_entries' + + static factory() { + const now = sqlDateTime(new Date()) + + return { + id: '{increment}', + user_id: 1, + task_id: 0, + project_id: 0, + // Completed by default (end set), within today so the default filter shows it. + start_time: now, + end_time: now, + comment: '', + created: now, + updated: now, + } + } +} diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 000000000..897ebe93c --- /dev/null +++ b/pkg/audit/audit_test.go @@ -0,0 +1,254 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package audit_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "code.vikunja.io/api/pkg/audit" + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue + os.Exit(m.Run()) +} + +// One event type per test so each topic has exactly the listeners the test registered. +type pipelineEvent struct { + TaskID int64 `json:"task_id"` + DoerID int64 `json:"doer_id"` +} + +func (e *pipelineEvent) Name() string { return "test.audit.pipeline" } + +type licenseGateEvent struct { + Marker string `json:"marker"` +} + +func (e *licenseGateEvent) Name() string { return "test.audit.licensegate" } + +type rotationEvent struct { + Filler string `json:"filler"` +} + +func (e *rotationEvent) Name() string { return "test.audit.rotation" } + +// otherListener is a second, non-audit listener on the same topic. +type otherListener struct { + called chan struct{} +} + +func (l *otherListener) Handle(_ *message.Message) error { + select { + case l.called <- struct{}{}: + default: + } + return nil +} + +func (l *otherListener) Name() string { return "other" } + +var ( + registerTestEventsOnce sync.Once + other = &otherListener{called: make(chan struct{}, 16)} +) + +// The listener registry is global and watermill rejects duplicate handler +// names, so register once per process (relevant for -count > 1). +func registerTestEvents() { + registerTestEventsOnce.Do(func() { + audit.RegisterEventForAudit(func(e *pipelineEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.UserActor(e.DoerID), + Target: audit.TaskTarget(e.TaskID), + } + }) + events.RegisterListener((&pipelineEvent{}).Name(), other) + + audit.RegisterEventForAudit(func(e *licenseGateEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"marker": e.Marker}, + } + }) + + audit.RegisterEventForAudit(func(e *rotationEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"filler": e.Filler}, + } + }) + }) +} + +func setupAuditFile(t *testing.T) string { + t.Helper() + logfile := filepath.Join(t.TempDir(), "audit.log") + config.AuditLogfile.Set(logfile) + require.NoError(t, audit.Init()) + t.Cleanup(audit.Close) + return logfile +} + +func startEventRouter(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ready, err := events.InitEventsForTesting(ctx) + require.NoError(t, err) + <-ready +} + +func waitForLines(t *testing.T, logfile string) []string { + t.Helper() + var lines []string + require.Eventually(t, func() bool { + content, err := os.ReadFile(logfile) + if err != nil { + return false + } + lines = strings.Split(strings.TrimSpace(string(content)), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + return len(lines) >= 1 + }, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line") + return lines +} + +func TestAuditPipeline(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + ctx := events.WithRequestMeta(context.Background(), &events.RequestMeta{ + IP: "192.0.2.42", + UserAgent: "test-agent/1.0", + RequestID: "req-123", + }) + require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7})) + + waitForLines(t, logfile) + select { + case <-other.called: + case <-time.After(5 * time.Second): + t.Fatal("other listener on the same topic was not called") + } + // A topic with multiple listeners must produce exactly one audit entry. + events.WaitForPendingHandlers() + lines := waitForLines(t, logfile) + require.Len(t, lines, 1) + + var entry audit.Entry + require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry)) + assert.NotEmpty(t, entry.EventID) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "task.created", entry.Action) + assert.Equal(t, audit.UserActor(7), entry.Actor) + assert.Equal(t, audit.TaskTarget(99), entry.Target) + assert.Equal(t, audit.OutcomeSuccess, entry.Outcome) + assert.Equal(t, "192.0.2.42", entry.Source.IP) + assert.Equal(t, "test-agent/1.0", entry.Source.UserAgent) + assert.Equal(t, audit.SourceHTTP, entry.Source.Type) + assert.Equal(t, "req-123", entry.RequestID) +} + +func TestAuditLicenseGating(t *testing.T) { + logfile := setupAuditFile(t) + + registerTestEvents() + startEventRouter(t) + + // Without the licensed feature nothing must be written. The license check + // happens per event at handle time, so give the async handler a settle + // window before flipping the license back on. + license.ResetForTests() + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "unlicensed"})) + require.Never(t, func() bool { + content, err := os.ReadFile(logfile) + return err == nil && len(content) > 0 + }, 500*time.Millisecond, 10*time.Millisecond, "unlicensed event must not be written") + events.WaitForPendingHandlers() + + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"})) + + lines := waitForLines(t, logfile) + require.Len(t, lines, 1) + assert.Contains(t, lines[0], `"marker":"licensed"`) + assert.NotContains(t, lines[0], "unlicensed") + assert.Contains(t, lines[0], `"type":"system"`) +} + +func TestAuditRotation(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + // Default max size is 100MB and config values are MB-granular, so two + // entries of ~600KB cross the limit with maxsizemb set to 1. + config.AuditRotationMaxSizeMB.Set("1") + t.Cleanup(func() { config.AuditRotationMaxSizeMB.Set("100") }) + require.NoError(t, audit.Init()) + + filler := strings.Repeat("x", 600*1024) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile) + + require.Eventually(t, func() bool { + rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log") + return err == nil && len(rotated) == 1 + }, 5*time.Second, 10*time.Millisecond, "expected one rotated audit log file") +} + +func TestWriteAuditEventNotInitialized(t *testing.T) { + audit.Close() + err := audit.WriteAuditEvent(&audit.Entry{Action: "task.created"}) + require.Error(t, err) +} diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go new file mode 100644 index 000000000..bb7f98493 --- /dev/null +++ b/pkg/audit/entry.go @@ -0,0 +1,154 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package audit persists an audit trail of authentication, authorization and +// data lifecycle events as JSONL. +// +// Events opt in via RegisterEventForAudit, which subscribes one audit +// listener per event on the existing watermill bus; the event→Entry mapping +// is a closure passed at registration. The catalog of audited events lives in +// registerEventsForAuditLogging in pkg/models/listeners.go. +// +// Entries reference actors and targets by opaque ID only — deleting a user +// row orphans their audit references, which satisfies GDPR erasure without +// log redaction. +// +// Audit logging is gated twice: registration on the audit.enabled config key, +// and each write on the licensed audit_logs feature. The license is checked +// per event because it can change at runtime; enabled-but-unlicensed means +// listeners run and write nothing. +// +// Request attribution (IP, user agent, request id) flows from an Echo +// middleware through the request context onto message metadata — see +// pkg/events.RequestMeta. Events dispatched outside a request get +// source type "system" instead. +// +// A failed file write is returned to the router for retry. Tamper evidence +// comes from filesystem permissions (the file is created 0600) plus shipping +// the file to an external system, not from hash chains or signatures. +// Rotation is size-based with age-based cleanup of rotated files; retention +// is the operator's concern. +package audit + +import "time" + +// Entry is one audit log record. It only references actors and targets by +// opaque ID — no names, emails or content — so GDPR erasure is satisfied by +// deleting the referenced row. +type Entry struct { + EventID string `json:"event_id"` // UUIDv7 + Timestamp time.Time `json:"timestamp"` + Actor Actor `json:"actor"` + Source Source `json:"source"` + Action string `json:"action"` + Target Target `json:"target"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + RequestID string `json:"request_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type actorType string +type targetType string + +// Actor is the principal which performed the audited action. +type Actor struct { + Type actorType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Source describes where the action originated from. +type Source struct { + Type string `json:"type"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +// Target is the resource the audited action was performed on. +type Target struct { + Type targetType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Outcome values for an Entry. +const ( + OutcomeSuccess = "success" + OutcomeFailure = "failure" +) + +// Source types for an Entry. +const ( + SourceHTTP = "http" + SourceSystem = "system" +) + +// The action catalog. Every audited action is listed here. +const ( + ActionLoginSucceeded = "auth.login.succeeded" + ActionLoginFailed = "auth.login.failed" + ActionLogout = "auth.logout" + ActionAPITokenIssued = "auth.api_token.issued" // #nosec G101 -- action identifier, not a credential + ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101 + ActionAPITokenUsed = "auth.api_token.used" // #nosec G101 + + ActionUserCreated = "user.created" + + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskAssigneeAdded = "task.assignee.added" + ActionTaskAssigneeRemoved = "task.assignee.removed" + ActionTaskCommentCreated = "task.comment.created" + ActionTaskCommentUpdated = "task.comment.updated" + ActionTaskCommentDeleted = "task.comment.deleted" + ActionTaskAttachmentCreated = "task.attachment.created" + ActionTaskAttachmentDeleted = "task.attachment.deleted" + ActionTaskRelationCreated = "task.relation.created" + ActionTaskRelationDeleted = "task.relation.deleted" + + ActionProjectCreated = "project.created" + ActionProjectUpdated = "project.updated" + ActionProjectDeleted = "project.deleted" + ActionProjectSharedWithUser = "project.shared.user" + ActionProjectSharedWithTeam = "project.shared.team" + + ActionTeamCreated = "team.created" + ActionTeamDeleted = "team.deleted" + ActionTeamMemberAdded = "team.member.added" + ActionTeamMemberRemoved = "team.member.removed" +) + +// The type strings are unexported; these constructors are the only way to +// build an Actor or Target, so a mismatched type/ID pair can't be expressed. + +func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} } +func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} } +func SystemActor() Actor { return Actor{Type: "system"} } + +// ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as +// users with negative IDs throughout the event payloads. +func ActorFromDoerID(id int64) Actor { + if id < 0 { + return LinkShareActor(-id) + } + return UserActor(id) +} + +func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} } +func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} } +func UserTarget(id int64) Target { return Target{Type: "user", ID: id} } +func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} } +func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} } diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go new file mode 100644 index 000000000..599a9b385 --- /dev/null +++ b/pkg/audit/listener.go @@ -0,0 +1,76 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package audit + +import ( + "encoding/json" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + + "github.com/ThreeDotsLabs/watermill/message" +) + +type auditListener struct { + handle func(msg *message.Message) error +} + +func (l *auditListener) Handle(msg *message.Message) error { + return l.handle(msg) +} + +func (l *auditListener) Name() string { + return "audit" +} + +// RegisterEventForAudit opts an event into audit logging. The event→Entry +// mapping is passed at registration, so opting in and defining the mapping +// are one unit and can't drift apart. Returning a nil Entry skips the event. +func RegisterEventForAudit[T any, PT interface { + *T + events.Event +}](toEntry func(PT) *Entry) { + name := PT(new(T)).Name() + events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error { + if !license.IsFeatureEnabled(license.FeatureAuditLogs) { + return nil // license is runtime-mutable — checked per event, not at registration + } + e := PT(new(T)) // fresh instance per message — handlers run concurrently + if err := json.Unmarshal(msg.Payload, e); err != nil { + return err + } + entry := toEntry(e) + if entry == nil { + return nil + } + enrichFromMetadata(entry, msg.Metadata) + return WriteAuditEvent(entry) + }}) +} + +func enrichFromMetadata(entry *Entry, meta message.Metadata) { + entry.Source.IP = meta.Get(events.MetadataKeyIP) + entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent) + entry.RequestID = meta.Get(events.MetadataKeyRequestID) + if entry.Source.Type == "" { + if entry.Source.IP != "" || entry.Source.UserAgent != "" { + entry.Source.Type = SourceHTTP + } else { + entry.Source.Type = SourceSystem + } + } +} diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go new file mode 100644 index 000000000..feccdb6f3 --- /dev/null +++ b/pkg/audit/writer.go @@ -0,0 +1,211 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package audit + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + + "github.com/google/uuid" +) + +var ( + mu sync.Mutex + initialized bool + logFile *os.File + logfilePath string + currentSize int64 + maxSizeBytes int64 + maxAge time.Duration + lastSync time.Time +) + +// Init opens the audit log file. +// Safe to call again to re-read the config (used by tests). +func Init() error { + mu.Lock() + defer mu.Unlock() + + closeLocked() + + logfilePath = config.AuditLogfile.GetString() + if logfilePath == "" { + logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log") + } + maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024 + maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour + + if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil { + return fmt.Errorf("could not create audit log directory: %w", err) + } + if err := openLogFileLocked(); err != nil { + return err + } + + initialized = true + return nil +} + +// Close closes the audit log file. Used by tests. +func Close() { + mu.Lock() + defer mu.Unlock() + closeLocked() +} + +func closeLocked() { + if logFile != nil { + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + } + initialized = false +} + +func openLogFileLocked() error { + f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err) + } + logFile = f + currentSize = info.Size() + return nil +} + +// WriteAuditEvent writes one entry to the local audit log. A failed write is +// returned so the event router retries it. +func WriteAuditEvent(entry *Entry) error { + if entry.EventID == "" { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("could not generate audit event id: %w", err) + } + entry.EventID = id.String() + } + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + if entry.Outcome == "" { + entry.Outcome = OutcomeSuccess + } + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("could not marshal audit entry: %w", err) + } + + mu.Lock() + if !initialized { + mu.Unlock() + return fmt.Errorf("audit log not initialized") + } + + if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil { + mu.Unlock() + return err + } + + // A failed rotation can leave us without an open file — retry the open + // here so writes self-heal via the router's retries instead of panicking. + if logFile == nil { + if err := openLogFileLocked(); err != nil { + mu.Unlock() + return err + } + } + + written, err := logFile.Write(append(line, '\n')) + currentSize += int64(written) + if err == nil && time.Since(lastSync) > time.Second { + err = logFile.Sync() + lastSync = time.Now() + } + mu.Unlock() + + if err != nil { + return fmt.Errorf("could not write audit entry: %w", err) + } + + return nil +} + +func rotateIfNeededLocked(addition int64) error { + if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes { + return nil + } + + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + + rotatedPath := rotatedFileName(logfilePath, time.Now().UTC()) + if err := os.Rename(logfilePath, rotatedPath); err != nil { + // Reopen the original so logging continues even if rotation failed. + if openErr := openLogFileLocked(); openErr != nil { + return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr) + } + return fmt.Errorf("could not rotate audit log: %w", err) + } + + cleanupRotatedFiles() + + return openLogFileLocked() +} + +func rotatedFileName(path string, now time.Time) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext +} + +func cleanupRotatedFiles() { + if maxAge <= 0 { + return + } + + ext := filepath.Ext(logfilePath) + pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext + matches, err := filepath.Glob(pattern) + if err != nil { + log.Errorf("Could not list rotated audit log files: %s", err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(match); err != nil { + log.Errorf("Could not remove old audit log file %s: %s", match, err) + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1941f7f0b..2443cb627 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -220,6 +220,11 @@ const ( WebhooksProxyPassword Key = `webhooks.proxypassword` WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips` + AuditEnabled Key = `audit.enabled` + AuditLogfile Key = `audit.logfile` + AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb` + AuditRotationMaxAge Key = `audit.rotation.maxage` + OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips` OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl` OutgoingRequestsProxyPassword Key = `outgoingrequests.proxypassword` @@ -483,6 +488,11 @@ func InitDefaultConfig() { WebhooksEnabled.setDefault(true) WebhooksTimeoutSeconds.setDefault(30) WebhooksAllowNonRoutableIPs.setDefault(false) + // Audit + AuditEnabled.setDefault(false) + AuditLogfile.setDefault("") // empty means /audit.log, resolved at init + AuditRotationMaxSizeMB.setDefault(100) + AuditRotationMaxAge.setDefault(30) // Outgoing Requests OutgoingRequestsAllowNonRoutableIPs.setDefault(false) OutgoingRequestsTimeoutSeconds.setDefault(30) diff --git a/pkg/db/db.go b/pkg/db/db.go index 829dcd997..108ad4245 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -525,5 +525,17 @@ func CreateParadeDBIndexes() error { return fmt.Errorf("could not ensure paradedb project index: %w", err) } + // Create ParadeDB index for time_entries (comment search via MultiFieldSearch) + timeEntriesIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_paradedb ON time_entries USING bm25 (id, comment) + WITH ( + key_field='id', + text_fields='{ + "comment": {"fast": true, "record": "freq"} + }' + )` + if _, err := x.Exec(timeEntriesIndexSQL); err != nil { + return fmt.Errorf("could not ensure paradedb time entry index: %w", err) + } + return nil } diff --git a/pkg/db/dump.go b/pkg/db/dump.go index 1d9f803b9..be345e173 100644 --- a/pkg/db/dump.go +++ b/pkg/db/dump.go @@ -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 } } diff --git a/pkg/db/fixtures/labels.yml b/pkg/db/fixtures/labels.yml index d685d6392..c84663d34 100644 --- a/pkg/db/fixtures/labels.yml +++ b/pkg/db/fixtures/labels.yml @@ -43,3 +43,11 @@ created_by_id: 1 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +# Covers the bot-owner branch: created by bot 23, whose owner is user 21. +# User 21 should be able to read/update/delete it; user 22 (who owns bot 24) +# should not. +- id: 9 + title: 'Label #9 - created by bot 23 owned by user 21' + created_by_id: 23 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/time_entries.yml b/pkg/db/fixtures/time_entries.yml new file mode 100644 index 000000000..a3a0112fb --- /dev/null +++ b/pkg/db/fixtures/time_entries.yml @@ -0,0 +1,36 @@ +- id: 1 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 10:00:00 + end_time: 2018-12-01 11:00:00 + comment: Time entry on task 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 2 + user_id: 1 + task_id: 0 + project_id: 1 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 3 + user_id: 3 + task_id: 0 + project_id: 3 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 3 by user3 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +# Running timer (no end_time) on task 1 by user1 +- id: 4 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 14:00:00 + comment: Running timer + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 diff --git a/pkg/db/fixtures/webhooks.yml b/pkg/db/fixtures/webhooks.yml index 4ec5687c7..983a03aff 100644 --- a/pkg/db/fixtures/webhooks.yml +++ b/pkg/db/fixtures/webhooks.yml @@ -41,3 +41,41 @@ created_by_id: 3 created: 2024-01-01 00:00:00 updated: 2024-01-01 00:00:00 +# Webhooks 6-8 are user-level (project_id null, user_id set) and back the v2 +# user-webhook tests. #6/#7 belong to user6; #6 carries credentials so masking +# can be asserted. #8 belongs to user1 so the owner-isolation check (user6 must +# not see or mutate another user's webhook) has a target. +# +# Event choice matters because the pkg/e2etests user-webhook suite shares these +# fixtures and dispatches real events. The WebhookListener fans a fired event out +# to ALL of the event-user's webhooks, asynchronously; a user-level fixture +# subscribed to a user-directed event the suite dispatches for its owner fires a +# real (failing) delivery to example.com, and that in-flight write then races the +# next test's fixture reload ("database table is locked: webhooks"). The suite +# dispatches user-directed events only for user1, so #6/#7 are owned by user6, and +# #8 (owned by user1) subscribes to task.updated — a project-only event the +# listener never matches for user webhooks. None of the three can fire there. +- id: 6 + target_url: "https://example.com/user-webhook-fixture" + events: '["task.reminder.fired"]' + user_id: 6 + secret: "uwh-secret-fixture" + basic_auth_user: "uwh-basicauth-user" + basic_auth_password: "uwh-basicauth-pass" + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 7 + target_url: "https://example.com/user-webhook-second" + events: '["task.reminder.fired"]' + user_id: 6 + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 8 + target_url: "https://example.com/user-webhook-other" + events: '["task.updated"]' + user_id: 1 + created_by_id: 1 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 diff --git a/pkg/events/events.go b/pkg/events/events.go index 30c26ea99..5973b132d 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -201,6 +201,13 @@ func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) { // Dispatch dispatches an event func Dispatch(event Event) error { + return DispatchWithContext(context.Background(), event) +} + +// DispatchWithContext dispatches an event and copies request metadata from the +// context (see WithRequestMeta) onto the message metadata, so listeners can +// attribute the event to the originating HTTP request. +func DispatchWithContext(ctx context.Context, event Event) error { if isUnderTest { dispatchedTestEvents = append(dispatchedTestEvents, event) return nil @@ -216,6 +223,17 @@ func Dispatch(event Event) error { } msg := message.NewMessage(watermill.NewUUID(), content) + if meta := RequestMetaFromContext(ctx); meta != nil { + if meta.IP != "" { + msg.Metadata.Set(MetadataKeyIP, meta.IP) + } + if meta.UserAgent != "" { + msg.Metadata.Set(MetadataKeyUserAgent, meta.UserAgent) + } + if meta.RequestID != "" { + msg.Metadata.Set(MetadataKeyRequestID, meta.RequestID) + } + } return pubsub.Publish(event.Name(), msg) } @@ -241,8 +259,9 @@ func DispatchOnCommit(key any, event Event) { // DispatchPending dispatches all events accumulated for the given key and removes them. // Call this after s.Commit(). Safe to call even if no events were registered. +// Request metadata on the context (see WithRequestMeta) is copied onto each message. // If any event fails to dispatch, the error is logged but remaining events are still dispatched. -func DispatchPending(key any) { +func DispatchPending(ctx context.Context, key any) { val, ok := pendingEvents.LoadAndDelete(key) if !ok { return @@ -251,7 +270,7 @@ func DispatchPending(key any) { // No need to lock here since we've already removed it from the map // and this key won't receive new events for _, event := range queue.events { - if err := Dispatch(event); err != nil { + if err := DispatchWithContext(ctx, event); err != nil { log.Errorf("Failed to dispatch event %s: %v", event.Name(), err) } } diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go index f78396a50..186d12f4a 100644 --- a/pkg/events/events_test.go +++ b/pkg/events/events_test.go @@ -17,6 +17,7 @@ package events import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -40,7 +41,7 @@ func TestDispatchOnCommit(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) // Simulate post-commit dispatch - DispatchPending(key) + DispatchPending(context.Background(), key) // Now it should be dispatched assert.Equal(t, 1, CountDispatchedEvents("test.event")) @@ -57,7 +58,7 @@ func TestDispatchOnCommitMultipleEvents(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 3, CountDispatchedEvents("test.event")) } @@ -74,7 +75,7 @@ func TestCleanupPending(t *testing.T) { CleanupPending(key) // Dispatching after cleanup should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 0, CountDispatchedEvents("test.event")) } @@ -85,7 +86,7 @@ func TestDispatchPendingNoEvents(t *testing.T) { key := new(int) // Should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) // Verify no events were dispatched assert.Equal(t, 0, CountDispatchedEvents("test.event")) diff --git a/pkg/events/request_meta.go b/pkg/events/request_meta.go new file mode 100644 index 000000000..796c7b7e9 --- /dev/null +++ b/pkg/events/request_meta.go @@ -0,0 +1,55 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package events + +import "context" + +// RequestMeta carries information about the originating HTTP request. It is +// stashed on the request context by a middleware and copied onto message +// metadata at publish time, so listeners (e.g. audit) can attribute an event +// to a request without every dispatch site changing its signature. +type RequestMeta struct { + IP string + UserAgent string + RequestID string +} + +// Message metadata keys holding request information. +const ( + MetadataKeyIP = "request_ip" + MetadataKeyUserAgent = "request_user_agent" + MetadataKeyRequestID = "request_id" +) + +type requestMetaKeyType struct{} + +var requestMetaKey requestMetaKeyType + +// WithRequestMeta returns a context carrying the given request metadata. +func WithRequestMeta(ctx context.Context, meta *RequestMeta) context.Context { + return context.WithValue(ctx, requestMetaKey, meta) +} + +// RequestMetaFromContext returns the request metadata stored on the context, +// or nil if there is none. +func RequestMetaFromContext(ctx context.Context) *RequestMeta { + if ctx == nil { + return nil + } + meta, _ := ctx.Value(requestMetaKey).(*RequestMeta) + return meta +} diff --git a/pkg/events/testing.go b/pkg/events/testing.go index 2c969f057..302886747 100644 --- a/pkg/events/testing.go +++ b/pkg/events/testing.go @@ -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 diff --git a/pkg/files/files.go b/pkg/files/files.go index a8d702913..5542a5f5f 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -37,12 +37,12 @@ import ( // File holds all information about a file type File struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` - Name string `xorm:"text not null" json:"name"` - Mime string `xorm:"text null" json:"mime"` - Size uint64 `xorm:"bigint not null" json:"size"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this file."` + Name string `xorm:"text not null" json:"name" readOnly:"true" doc:"The original name of the uploaded file."` + Mime string `xorm:"text null" json:"mime" readOnly:"true" doc:"The detected mime type of the file."` + Size uint64 `xorm:"bigint not null" json:"size" readOnly:"true" doc:"The size of the file in bytes."` - Created time.Time `xorm:"created" json:"created"` + Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this file was uploaded."` CreatedByID int64 `xorm:"bigint not null" json:"-"` File io.ReadCloser `xorm:"-" json:"-"` diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index dca17cb60..7210feb1e 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -19,6 +19,7 @@ package initialize import ( "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/db" @@ -98,6 +99,12 @@ func FullInitWithoutAsync() { // See the package comment in pkg/license/license.go before removing. license.Init() + if config.AuditEnabled.GetBool() { + if err := audit.Init(); err != nil { + log.Fatalf("Could not initialize audit logging: %s", err) + } + } + // Start the mail daemon mail.StartMailDaemon() diff --git a/pkg/migration/20260607132257.go b/pkg/migration/20260607132257.go new file mode 100644 index 000000000..f2a7e76b3 --- /dev/null +++ b/pkg/migration/20260607132257.go @@ -0,0 +1,55 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migration + +import ( + "time" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +// Mirrors models.TimeEntry. No partial unique index for the single-active-timer +// rule — MySQL has no filtered indexes; it's enforced in the model instead. +type TimeEntry20260607132257 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + UserID int64 `xorm:"bigint not null INDEX"` + TaskID int64 `xorm:"bigint null INDEX"` + ProjectID int64 `xorm:"bigint null INDEX"` + StartTime time.Time `xorm:"not null INDEX"` + EndTime *time.Time `xorm:"null"` + Comment string `xorm:"text null"` + Created time.Time `xorm:"created not null"` + Updated time.Time `xorm:"updated not null"` +} + +func (TimeEntry20260607132257) TableName() string { + return "time_entries" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260607132257", + Description: "Add time_entries table", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync(TimeEntry20260607132257{}) + }, + Rollback: func(tx *xorm.Engine) error { + return tx.DropTables(TimeEntry20260607132257{}) + }, + }) +} diff --git a/pkg/migration/20260617153629.go b/pkg/migration/20260617153629.go new file mode 100644 index 000000000..d5ae3d772 --- /dev/null +++ b/pkg/migration/20260617153629.go @@ -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 . + +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 + }, + }) +} diff --git a/pkg/migration/20260619155410.go b/pkg/migration/20260619155410.go new file mode 100644 index 000000000..97f91bb5e --- /dev/null +++ b/pkg/migration/20260619155410.go @@ -0,0 +1,55 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package 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 + }, + }) +} diff --git a/pkg/models/admin_overview.go b/pkg/models/admin_overview.go new file mode 100644 index 000000000..082d6c81d --- /dev/null +++ b/pkg/models/admin_overview.go @@ -0,0 +1,83 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/license" + + "xorm.io/xorm" +) + +type ShareCounts struct { + LinkShares int64 `json:"link_shares" readOnly:"true" doc:"Number of link shares across all projects."` + TeamShares int64 `json:"team_shares" readOnly:"true" doc:"Number of team-project shares."` + UserShares int64 `json:"user_shares" readOnly:"true" doc:"Number of user-project shares."` +} + +type Overview struct { + Users int64 `json:"users" readOnly:"true" doc:"Total number of user accounts."` + Projects int64 `json:"projects" readOnly:"true" doc:"Total number of projects."` + Tasks int64 `json:"tasks" readOnly:"true" doc:"Total number of tasks."` + Teams int64 `json:"teams" readOnly:"true" doc:"Total number of teams."` + Shares ShareCounts `json:"shares" readOnly:"true" doc:"Aggregate share counts."` + License license.Info `json:"license" readOnly:"true" doc:"Snapshot of the instance license state."` +} + +// BuildOverview returns aggregate instance counts plus the current license snapshot. +func BuildOverview(s *xorm.Session) (*Overview, error) { + users, err := s.Table("users").Count() + if err != nil { + return nil, err + } + projects, err := s.Table("projects").Count() + if err != nil { + return nil, err + } + tasks, err := s.Table("tasks").Count() + if err != nil { + return nil, err + } + teams, err := s.Table("teams").Count() + if err != nil { + return nil, err + } + linkShares, err := s.Table("link_shares").Count() + if err != nil { + return nil, err + } + teamShares, err := s.Table("team_projects").Count() + if err != nil { + return nil, err + } + userShares, err := s.Table("users_projects").Count() + if err != nil { + return nil, err + } + + return &Overview{ + Users: users, + Projects: projects, + Tasks: tasks, + Teams: teams, + Shares: ShareCounts{ + LinkShares: linkShares, + TeamShares: teamShares, + UserShares: userShares, + }, + License: license.CurrentInfo(), + }, nil +} diff --git a/pkg/models/admin_user_actions.go b/pkg/models/admin_user_actions.go new file mode 100644 index 000000000..9918eaafb --- /dev/null +++ b/pkg/models/admin_user_actions.go @@ -0,0 +1,106 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// loadAdminTargetUser fetches a user by ID for the admin actions, returning +// ErrUserDoesNotExist for an invalid ID or a missing row. +func loadAdminTargetUser(s *xorm.Session, id int64) (*user.User, error) { + if id < 1 { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + target := &user.User{ID: id} + has, err := s.Get(target) + if err != nil { + return nil, err + } + if !has { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + return target, nil +} + +// SetUserAdminFlag sets a user's instance-admin flag. Demoting the last +// reachable admin is refused via GuardLastAdmin. It does not commit; the caller +// owns the transaction. +func SetUserAdminFlag(s *xorm.Session, id int64, isAdmin bool) (*user.User, error) { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return nil, err + } + + if !isAdmin { + if err := user.GuardLastAdmin(s, target); err != nil { + return nil, err + } + } + + target.IsAdmin = isAdmin + if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { + return nil, err + } + return target, nil +} + +// SetUserStatusAsAdmin sets a user's account status. Moving the last reachable +// admin out of Active is refused via GuardLastAdmin (any non-Active status +// blocks login, so it is equivalent to demotion). It does not commit; the caller +// owns the transaction. +func SetUserStatusAsAdmin(s *xorm.Session, id int64, status user.Status) (*user.User, error) { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return nil, err + } + + if target.IsAdmin && status != user.StatusActive { + if err := user.GuardLastAdmin(s, target); err != nil { + return nil, err + } + } + + if err := user.SetUserStatus(s, target, status); err != nil { + return nil, err + } + // Reflect the change on the returned struct; GetUserByID refuses disabled accounts. + target.Status = status + return target, nil +} + +// DeleteUserAsAdmin removes a user. mode "now" deletes immediately; any other +// value triggers the email-confirmation self-deletion flow. Deleting the last +// reachable admin is refused via GuardLastAdmin. It does not commit; the caller +// owns the transaction. +func DeleteUserAsAdmin(s *xorm.Session, id int64, mode string) error { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return err + } + + if err := user.GuardLastAdmin(s, target); err != nil { + return err + } + + if mode == "now" { + return DeleteUser(s, target) + } + return user.RequestDeletion(s, target) +} diff --git a/pkg/models/admin_user_create.go b/pkg/models/admin_user_create.go new file mode 100644 index 000000000..a54d328a0 --- /dev/null +++ b/pkg/models/admin_user_create.go @@ -0,0 +1,80 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// CreateUserBody wraps user.APIUserPassword with admin-only fields. +type CreateUserBody struct { + // The full name of the new user. Optional. + Name string `json:"name" doc:"The full name of the new user. Optional."` + // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. + Language string `json:"language" valid:"language" doc:"IETF BCP 47 language code; must exist in Vikunja."` + user.APIUserPassword + // Mark the new user as an instance admin. + IsAdmin bool `json:"is_admin" doc:"Mark the new user as an instance admin."` + // Activate the new user immediately without email confirmation. + SkipEmailConfirm bool `json:"skip_email_confirm" doc:"Activate the new user immediately, skipping email confirmation."` +} + +// CreateUserAsAdmin provisions a new local account on behalf of an instance admin, +// honouring the admin-only is_admin and skip_email_confirm fields and bypassing the +// public-registration toggle. It commits s and returns the persisted user reloaded +// so the status reflects what was actually stored. +func CreateUserAsAdmin(s *xorm.Session, body *CreateUserBody) (*user.User, error) { + newUser, err := RegisterUser(s, &user.User{ + Username: body.Username, + Password: body.Password, + Email: body.Email, + Name: body.Name, + Language: body.Language, + }) + if err != nil { + return nil, err + } + + if body.IsAdmin { + if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil { + return nil, err + } + newUser.IsAdmin = true + } + + // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation. + if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() { + if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil { + return nil, err + } + newUser.Status = user.StatusActive + } + + if err := s.Commit(); err != nil { + return nil, err + } + + // Reload on a fresh session so the returned status reflects what was actually + // persisted (e.g. StatusEmailConfirmationRequired on mail-enabled instances). + rs := db.NewSession() + defer rs.Close() + return user.GetUserByID(rs, newUser.ID) +} diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index 2264a5121..d7a8a437d 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -29,8 +29,8 @@ var apiTokenRoutes = map[string]APITokenRoute{} // apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission) // keys as v1, so a token granted e.g. labels.read_one authorises both -// versions. The frontend token UI still reads only apiTokenRoutes; -// CanDoAPIRoute consults both tables. +// versions. CanDoAPIRoute consults both tables; GetAPITokenRoutes (the /routes +// exposure the frontend reads) merges v2-only groups so they're discoverable. var apiTokenRoutesV2 = map[string]APITokenRoute{} func init() { @@ -183,6 +183,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b "comments": true, "relations": true, "attachments": true, + "time-entries": true, "projects_views": true, "projects_teams": true, "projects_users": true, @@ -345,10 +346,30 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) { } -// GetAPITokenRoutes exposes the registered scoped-token routes so tests -// and the /api/v1/routes handler share a single source of truth. +// GetAPITokenRoutes exposes the registered scoped-token routes for the /routes +// handler and tests. v1 is the base; v2-only groups and permissions (a v2-only +// resource like time-entries has no v1 counterpart) are merged in so tokens can +// discover and grant them. Shared (group, permission) keys keep their v1 entry — +// CanDoAPIRoute authorises both versions off the same key regardless. func GetAPITokenRoutes() map[string]APITokenRoute { - return apiTokenRoutes + merged := make(map[string]APITokenRoute, len(apiTokenRoutes)) + for group, perms := range apiTokenRoutes { + merged[group] = make(APITokenRoute, len(perms)) + for perm, rd := range perms { + merged[group][perm] = rd + } + } + for group, perms := range apiTokenRoutesV2 { + if merged[group] == nil { + merged[group] = make(APITokenRoute) + } + for perm, rd := range perms { + if merged[group][perm] == nil { + merged[group][perm] = rd + } + } + } + return merged } // GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage. @@ -406,7 +427,8 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) { // Two list endpoints share tasks.read_all but only one // survives collection, so allow either explicitly. if group == "tasks" && p == "read_all" && method == http.MethodGet && - (path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") { + (path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks" || + path == "/api/v2/tasks" || path == "/api/v2/projects/:project/tasks") { return true } } diff --git a/pkg/models/api_routes_test.go b/pkg/models/api_routes_test.go index b4b0e1661..1cbe6c5fb 100644 --- a/pkg/models/api_routes_test.go +++ b/pkg/models/api_routes_test.go @@ -121,6 +121,55 @@ func TestCollectRoutesV2(t *testing.T) { assert.Equal(t, "DELETE", labels["delete"].Method) } +// TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource +// lands under a clean "time-entries" group rather than the "other" catch-all, +// so its scopes read sensibly for token clients. +func TestCollectRoutes_TimeEntriesV2(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries/:id"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/time-entries"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/time-entries/:id"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/time-entries/:id"}, true) + + _, isOther := apiTokenRoutesV2["other"] + assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket") + + te, has := apiTokenRoutesV2["time-entries"] + require.True(t, has, "time-entries group should exist in the v2 table") + assert.Equal(t, "GET", te["read_all"].Method) + assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path) + assert.Equal(t, "GET", te["read_one"].Method) + assert.Equal(t, "POST", te["create"].Method) + assert.Equal(t, "PUT", te["update"].Method) + assert.Equal(t, "DELETE", te["delete"].Method) +} + +// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges +// v2-only groups (time-entries has no v1 counterpart) so token clients can +// discover and grant them, without mutating the v1 table itself. +func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v1/labels"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true) + + routes := GetAPITokenRoutes() + + _, hasLabels := routes["labels"] + assert.True(t, hasLabels, "v1 groups stay exposed") + + te, hasTE := routes["time-entries"] + require.True(t, hasTE, "v2-only time-entries must be exposed via /routes") + assert.Equal(t, "GET", te["read_all"].Method) + + _, v1HasTE := apiTokenRoutes["time-entries"] + assert.False(t, v1HasTE, "the merge must not mutate the v1 table") +} + // TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create, // PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping // path. @@ -197,6 +246,40 @@ func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) { }) } +// TestCanDoAPIRoute_V2TasksReadAll verifies that tasks.read_all authorises +// both the global /api/v2/tasks and project-scoped /api/v2/projects/:project/tasks +// endpoints. Both normalise to tasks.read_all via getRouteGroupName, but only +// one RouteDetail survives in the map — the special case in CanDoAPIRoute must +// accept either path. +func TestCanDoAPIRoute_V2TasksReadAll(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + apiTokenRoutes["caldav"] = APITokenRoute{ + "access": &RouteDetail{Path: "/dav/*", Method: "ANY"}, + } + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/tasks"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/projects/:project/tasks"}, true) + + token := &APIToken{ + APIPermissions: APIPermissions{"tasks": []string{"read_all"}}, + } + + e := echo.New() + + t.Run("global /api/v2/tasks", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v2/tasks", nil) + c := e.NewContext(req, httptest.NewRecorder()) + assert.True(t, CanDoAPIRoute(c, token)) + }) + + t.Run("project-scoped /api/v2/projects/:project/tasks", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v2/projects/:project/tasks", nil) + c := e.NewContext(req, httptest.NewRecorder()) + assert.True(t, CanDoAPIRoute(c, token)) + }) +} + // End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label // integration test in pkg/webtests/huma_label_test.go (see the token-auth // scenarios in that file) which exercises the full auth pipeline. diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 410c4ac96..7739184fb 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -24,6 +24,7 @@ import ( "time" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/web" @@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { } _, err = s.Insert(t) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenIssuedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + OwnerID: t.OwnerID, + }) + + return nil } func HashToken(token, salt string) string { @@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, // @Failure 404 {object} web.HTTPError "The token does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /tokens/{tokenID} [delete] -func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) { +func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) { // Ownership is verified in CanDelete; delete by ID only. _, err = s.Where("id = ?", t.ID).Delete(&APIToken{}) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenRevokedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + }) + + return nil } // HasCaldavAccess checks whether the token has the caldav access permission. diff --git a/pkg/models/bulk_task.go b/pkg/models/bulk_task.go index c103bc713..a0e294422 100644 --- a/pkg/models/bulk_task.go +++ b/pkg/models/bulk_task.go @@ -24,10 +24,10 @@ import ( // BulkTask represents a bulk task update payload. type BulkTask struct { - TaskIDs []int64 `json:"task_ids"` - Fields []string `json:"fields"` - Values *Task `json:"values"` - Tasks []*Task `json:"tasks,omitempty"` + TaskIDs []int64 `json:"task_ids" doc:"The ids of the tasks to update. The user needs write access to every project these tasks belong to, or the whole request is rejected."` + Fields []string `json:"fields" doc:"The names of the task fields to apply from values; only these fields are written, the rest of each task is left untouched."` + Values *Task `json:"values" doc:"The task carrying the values to set. Only the fields named in fields are read from it and applied to every task."` + Tasks []*Task `json:"tasks,omitempty" readOnly:"true" doc:"The updated tasks, returned in the response."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` diff --git a/pkg/models/error.go b/pkg/models/error.go index bd15c2284..8f1a47553 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -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 // ============== @@ -2398,3 +2426,230 @@ func (err *ErrOAuthInvalidGrantType) HTTPError() web.HTTPError { Message: "The grant_type is not supported. Use 'authorization_code' or 'refresh_token'.", } } + +// ==================== +// Time Tracking Errors +// ==================== + +// ErrTimeEntryDoesNotExist represents an error where a time entry does not exist +type ErrTimeEntryDoesNotExist struct { + TimeEntryID int64 +} + +// IsErrTimeEntryDoesNotExist checks if an error is ErrTimeEntryDoesNotExist. +func IsErrTimeEntryDoesNotExist(err error) bool { + _, ok := err.(ErrTimeEntryDoesNotExist) + return ok +} + +func (err ErrTimeEntryDoesNotExist) Error() string { + return fmt.Sprintf("Time entry does not exist [TimeEntryID: %v]", err.TimeEntryID) +} + +// ErrCodeTimeEntryDoesNotExist holds the unique world-error code of this error +const ErrCodeTimeEntryDoesNotExist = 18001 + +// HTTPError holds the http error description +func (err ErrTimeEntryDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeTimeEntryDoesNotExist, + Message: "This time entry does not exist.", + } +} + +// ErrTimeEntryInvalidContainer represents an error where a time entry is attached +// to both a task and a project, or to neither (violating the XOR invariant). +type ErrTimeEntryInvalidContainer struct { + TaskID int64 + ProjectID int64 +} + +// IsErrTimeEntryInvalidContainer checks if an error is ErrTimeEntryInvalidContainer. +func IsErrTimeEntryInvalidContainer(err error) bool { + _, ok := err.(ErrTimeEntryInvalidContainer) + return ok +} + +func (err ErrTimeEntryInvalidContainer) Error() string { + return fmt.Sprintf("Time entry must be attached to exactly one of task or project [TaskID: %v, ProjectID: %v]", err.TaskID, err.ProjectID) +} + +// ErrCodeTimeEntryInvalidContainer holds the unique world-error code of this error +const ErrCodeTimeEntryInvalidContainer = 18002 + +// HTTPError holds the http error description +func (err ErrTimeEntryInvalidContainer) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeTimeEntryInvalidContainer, + Message: "A time entry must be attached to exactly one of a task or a project.", + } +} + +// ErrInvalidTimeEntryFilterField represents an error where a time entry filter references a non-filterable field +type ErrInvalidTimeEntryFilterField struct { + Field string +} + +// IsErrInvalidTimeEntryFilterField checks if an error is ErrInvalidTimeEntryFilterField. +func IsErrInvalidTimeEntryFilterField(err error) bool { + _, ok := err.(ErrInvalidTimeEntryFilterField) + return ok +} + +func (err ErrInvalidTimeEntryFilterField) Error() string { + return fmt.Sprintf("Time entry filter field is invalid [Field: %s]", err.Field) +} + +// ErrCodeInvalidTimeEntryFilterField holds the unique world-error code of this error +const ErrCodeInvalidTimeEntryFilterField = 18003 + +// HTTPError holds the http error description +func (err ErrInvalidTimeEntryFilterField) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidTimeEntryFilterField, + Message: fmt.Sprintf("The time entry filter field '%s' is invalid. Filterable fields are user_id, task_id, project_id, start_time and end_time.", err.Field), + } +} + +// ErrInvalidTimeEntryFilterValue represents an error where a time entry filter value cannot be parsed for its field +type ErrInvalidTimeEntryFilterValue struct { + Field string + Value string +} + +// IsErrInvalidTimeEntryFilterValue checks if an error is ErrInvalidTimeEntryFilterValue. +func IsErrInvalidTimeEntryFilterValue(err error) bool { + _, ok := err.(ErrInvalidTimeEntryFilterValue) + return ok +} + +func (err ErrInvalidTimeEntryFilterValue) Error() string { + return fmt.Sprintf("Time entry filter value is invalid [Field: %s, Value: %s]", err.Field, err.Value) +} + +// ErrCodeInvalidTimeEntryFilterValue holds the unique world-error code of this error +const ErrCodeInvalidTimeEntryFilterValue = 18004 + +// HTTPError holds the http error description +func (err ErrInvalidTimeEntryFilterValue) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidTimeEntryFilterValue, + Message: fmt.Sprintf("The value '%s' is not valid for the time entry filter field '%s'.", err.Value, err.Field), + } +} + +// ErrNoRunningTimer represents an error where a user has no running timer to act on +type ErrNoRunningTimer struct { + UserID int64 +} + +// IsErrNoRunningTimer checks if an error is ErrNoRunningTimer. +func IsErrNoRunningTimer(err error) bool { + _, ok := err.(ErrNoRunningTimer) + return ok +} + +func (err ErrNoRunningTimer) Error() string { + return fmt.Sprintf("No running timer [UserID: %d]", err.UserID) +} + +// ErrCodeNoRunningTimer holds the unique world-error code of this error +const ErrCodeNoRunningTimer = 18005 + +// HTTPError holds the http error description +func (err ErrNoRunningTimer) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeNoRunningTimer, + Message: "You do not have a running timer.", + } +} + +// ErrTimeEntryAlreadyEnded represents an error where an update tries to clear the +// end time of an entry that has already ended (reopening it as a running timer). +type ErrTimeEntryAlreadyEnded struct { + TimeEntryID int64 +} + +// IsErrTimeEntryAlreadyEnded checks if an error is ErrTimeEntryAlreadyEnded. +func IsErrTimeEntryAlreadyEnded(err error) bool { + _, ok := err.(ErrTimeEntryAlreadyEnded) + return ok +} + +func (err ErrTimeEntryAlreadyEnded) Error() string { + return fmt.Sprintf("Time entry has already ended and cannot be reopened [TimeEntryID: %v]", err.TimeEntryID) +} + +// ErrCodeTimeEntryAlreadyEnded holds the unique world-error code of this error +const ErrCodeTimeEntryAlreadyEnded = 18006 + +// HTTPError holds the http error description +func (err ErrTimeEntryAlreadyEnded) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeTimeEntryAlreadyEnded, + Message: "A time entry that has already ended cannot be reopened into a running timer. Start a new timer instead.", + } +} + +// ErrTimeEntryEndBeforeStart represents an error where a time entry's end time +// precedes its start time, which would persist a negative interval. +type ErrTimeEntryEndBeforeStart struct { + TimeEntryID int64 +} + +// IsErrTimeEntryEndBeforeStart checks if an error is ErrTimeEntryEndBeforeStart. +func IsErrTimeEntryEndBeforeStart(err error) bool { + _, ok := err.(ErrTimeEntryEndBeforeStart) + return ok +} + +func (err ErrTimeEntryEndBeforeStart) Error() string { + return fmt.Sprintf("Time entry end time is before its start time [TimeEntryID: %v]", err.TimeEntryID) +} + +// ErrCodeTimeEntryEndBeforeStart holds the unique world-error code of this error +const ErrCodeTimeEntryEndBeforeStart = 18007 + +// HTTPError holds the http error description +func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeTimeEntryEndBeforeStart, + 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.", + } +} diff --git a/pkg/models/events.go b/pkg/models/events.go index 937524a1a..b938345f4 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -18,7 +18,6 @@ package models import ( "code.vikunja.io/api/pkg/user" - "code.vikunja.io/api/pkg/web" ) ///////////////// @@ -230,8 +229,8 @@ func (l *ProjectCreatedEvent) Name() string { // ProjectUpdatedEvent represents an event where a project has been updated type ProjectUpdatedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectUpdatedEvent @@ -241,8 +240,8 @@ func (p *ProjectUpdatedEvent) Name() string { // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectDeletedEvent @@ -258,7 +257,7 @@ func (p *ProjectDeletedEvent) Name() string { type ProjectSharedWithUserEvent struct { Project *Project `json:"project"` User *user.User `json:"user"` - Doer web.Auth `json:"doer"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithUserEvent @@ -268,9 +267,9 @@ func (p *ProjectSharedWithUserEvent) Name() string { // ProjectSharedWithTeamEvent represents an event where a project has been shared with a team type ProjectSharedWithTeamEvent struct { - Project *Project `json:"project"` - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithTeamEvent @@ -308,8 +307,8 @@ func (t *TeamMemberRemovedEvent) Name() string { // TeamCreatedEvent represents a TeamCreatedEvent event type TeamCreatedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamCreatedEvent @@ -319,8 +318,8 @@ func (t *TeamCreatedEvent) Name() string { // TeamDeletedEvent represents a TeamDeletedEvent event type TeamDeletedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamDeletedEvent @@ -362,3 +361,77 @@ type WebhookDeliveryEvent struct { func (w *WebhookDeliveryEvent) Name() string { return "webhook.delivery" } + +// TimeEntryCreatedEvent represents a time entry being created +type TimeEntryCreatedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryCreatedEvent +func (e *TimeEntryCreatedEvent) Name() string { + return "time-entry.created" +} + +// TimeEntryUpdatedEvent represents a time entry being updated (including a timer being stopped) +type TimeEntryUpdatedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryUpdatedEvent +func (e *TimeEntryUpdatedEvent) Name() string { + return "time-entry.updated" +} + +// TimeEntryDeletedEvent represents a time entry being deleted +type TimeEntryDeletedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryDeletedEvent +func (e *TimeEntryDeletedEvent) Name() string { + return "time-entry.deleted" +} + +//////////////////// +// API Token Events + +// API token events carry IDs only: the freshly created token struct holds the +// raw token string, which must never end up in a message payload (the poison +// queue logs payloads on handler failure). + +// APITokenIssuedEvent represents an API token being created +type APITokenIssuedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenIssuedEvent +func (e *APITokenIssuedEvent) Name() string { + return "api-token.issued" +} + +// APITokenRevokedEvent represents an API token being deleted +type APITokenRevokedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` +} + +// Name defines the name for APITokenRevokedEvent +func (e *APITokenRevokedEvent) Name() string { + return "api-token.revoked" +} + +// APITokenUsedEvent represents an API token authenticating a request +type APITokenUsedEvent struct { + TokenID int64 `json:"token_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenUsedEvent +func (e *APITokenUsedEvent) Name() string { + return "api-token.used" +} diff --git a/pkg/models/export.go b/pkg/models/export.go index 4772fa2d5..2d9b57651 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -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] " diff --git a/pkg/models/export_test.go b/pkg/models/export_test.go new file mode 100644 index 000000000..f2d0fa2fb --- /dev/null +++ b/pkg/models/export_test.go @@ -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 . + +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) + }) +} diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index 0b2b06eaf..cd58e6928 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -21,25 +21,26 @@ 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. // A task can only appear once per project view which is ensured by a // unique index on the combination of task_id and project_view_id. type TaskBucket struct { - BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket"` - Bucket *Bucket `xorm:"-" json:"bucket"` + BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket" doc:"The bucket to move the task into. On /api/v2 this is taken from the URL; a value in the body is ignored."` + Bucket *Bucket `xorm:"-" json:"bucket" readOnly:"true" doc:"The resolved target bucket, including its updated task count."` // The task which belongs to the bucket. Together with ProjectViewID // this field is part of a unique index to prevent duplicates. - TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id"` + TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id" doc:"The id of the task to place in the bucket."` // The view this bucket belongs to. Combined with TaskID this forms a // unique index. - ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view"` + ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view" doc:"The view the bucket belongs to. On /api/v2 this is taken from the URL; a value in the body is ignored."` ProjectID int64 `xorm:"-" json:"-" param:"project"` - Task *Task `xorm:"-" json:"task"` + Task *Task `xorm:"-" json:"task" readOnly:"true" doc:"The task as it stands after the move, reflecting any done-state change."` web.Permissions `xorm:"-" json:"-"` web.CRUDable `xorm:"-" json:"-"` @@ -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 diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..bf5c42ac3 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -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} diff --git a/pkg/models/label_permissions.go b/pkg/models/label_permissions.go index 75468d1bb..2854cbf7b 100644 --- a/pkg/models/label_permissions.go +++ b/pkg/models/label_permissions.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/builder" "xorm.io/xorm" @@ -57,7 +58,19 @@ func (l *Label) isLabelOwner(s *xorm.Session, a web.Auth) (bool, error) { if err != nil { return false, err } - return lorig.CreatedByID == a.GetID(), nil + if lorig.CreatedByID == a.GetID() { + return true, nil + } + + // A bot owner inherits write/delete access to labels their bots created. + creator, err := user.GetUserByID(s, lorig.CreatedByID) + if err != nil { + if user.IsErrUserDoesNotExist(err) { + return false, nil + } + return false, err + } + return creator.IsBot() && creator.BotOwnerID == a.GetID(), nil } // hasAccessToLabel reports whether the caller can read a label and, if so, @@ -91,7 +104,12 @@ func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxPerm accessBranches := []builder.Cond{labelAttachedToAccessibleTask} if !isLinkShare { - accessBranches = append(accessBranches, builder.Eq{"labels.created_by_id": a.GetID()}) + accessBranches = append(accessBranches, + builder.Eq{"labels.created_by_id": a.GetID()}, + builder.In("labels.created_by_id", + builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": a.GetID()}), + ), + ) } cond := builder.And( diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index ad8b46c2c..024b0e039 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -212,7 +212,12 @@ func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*Lab ), cond) } if opts.GetUnusedLabels && !isLinkShareAuth { - cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.GetID()}) + cond = builder.Or(cond, + builder.Eq{"labels.created_by_id": opts.User.GetID()}, + builder.In("labels.created_by_id", + builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": opts.User.GetID()}), + ), + ) } ids := []int64{} @@ -410,7 +415,7 @@ func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Lab // LabelTaskBulk is a helper struct to update a bunch of labels at once type LabelTaskBulk struct { // All labels you want to update at once. - Labels []*Label `json:"labels"` + Labels []*Label `json:"labels" doc:"The complete set of labels the task should have after the call. Any label currently on the task that is not in this list is removed; any label in the list that is not yet on the task is added. You must be able to see every label you attach."` TaskID int64 `json:"-" param:"projecttask"` web.CRUDable `json:"-"` diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index 5320678b3..02bff9f06 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -336,6 +336,46 @@ func TestLabel_ReadOne(t *testing.T) { wantForbidden: true, auth: &user.User{ID: 1}, }, + { + // Label 9 was created by bot 23, whose owner is user 21. The + // bot owner inherits admin-level access. + name: "bot owner can read label created by their bot", + fields: fields{ + ID: 9, + }, + want: &Label{ + ID: 9, + Title: "Label #9 - created by bot 23 owned by user 21", + CreatedByID: 23, + CreatedBy: &user.User{ + ID: 23, + Name: "Owner A Assistant", + Username: "bot-owner-a-assistant", + Issuer: "local", + BotOwnerID: 21, + EmailRemindersEnabled: true, + OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", + Created: testCreatedTime, + Updated: testUpdatedTime, + }, + Created: testCreatedTime, + Updated: testUpdatedTime, + }, + auth: &user.User{ID: 21}, + assertMaxPermission: true, + wantMaxPermission: int(PermissionAdmin), + }, + { + // User 22 owns a different bot and must not see another owner's + // bot's label. + name: "non-owner cannot read label created by someone else's bot", + fields: fields{ + ID: 9, + }, + wantForbidden: true, + auth: &user.User{ID: 22}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -507,6 +547,27 @@ func TestLabel_Update(t *testing.T) { auth: &user.User{ID: 1}, wantForbidden: true, }, + { + // Label 9 was created by bot 23 (owned by user 21). The bot's + // owner inherits update permission. + name: "bot owner can update label created by their bot", + fields: fields{ + ID: 9, + Title: "new and better", + }, + auth: &user.User{ID: 21}, + }, + { + // User 22 owns a different bot and must not be able to update + // another owner's bot's label. + name: "non-owner cannot update label created by someone else's bot", + fields: fields{ + ID: 9, + Title: "new and better", + }, + auth: &user.User{ID: 22}, + wantForbidden: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -594,6 +655,25 @@ func TestLabel_Delete(t *testing.T) { auth: &user.User{ID: 1}, wantForbidden: true, }, + { + // Label 9 was created by bot 23 (owned by user 21). The bot's + // owner inherits delete permission. + name: "bot owner can delete label created by their bot", + fields: fields{ + ID: 9, + }, + auth: &user.User{ID: 21}, + }, + { + // User 22 owns a different bot and must not be able to delete + // another owner's bot's label. + name: "non-owner cannot delete label created by someone else's bot", + fields: fields{ + ID: 9, + }, + auth: &user.User{ID: 22}, + wantForbidden: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 83ec34c9b..e50631ae1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" @@ -82,6 +83,249 @@ func RegisterListeners() { // Internal delivery listener — one message per webhook with its own retry lifecycle events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{}) } + if config.AuditEnabled.GetBool() { + registerEventsForAuditLogging() + } +} + +func auditActorFromUser(u *user.User) audit.Actor { + if u == nil { + return audit.SystemActor() + } + return audit.ActorFromDoerID(u.ID) +} + +// registerEventsForAuditLogging opts events into audit logging. This block is +// the catalog of the entire audited surface — an event without a registration +// here is not audited. +func registerEventsForAuditLogging() { + // Auth boundary + audit.RegisterEventForAudit(func(e *user.LoginSucceededEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginSucceeded, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + audit.RegisterEventForAudit(func(e *user.LoginFailedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginFailed, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + Outcome: audit.OutcomeFailure, + Reason: "wrong password", + } + }) + audit.RegisterEventForAudit(func(e *user.LogoutEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLogout, + Actor: audit.UserActor(e.UserID), + Target: audit.UserTarget(e.UserID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenIssuedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenIssued, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + Metadata: map[string]any{"owner_id": e.OwnerID}, + } + }) + audit.RegisterEventForAudit(func(e *APITokenRevokedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenRevoked, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenUsedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenUsed, + Actor: audit.UserActor(e.OwnerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + + // Users + audit.RegisterEventForAudit(func(e *user.CreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionUserCreated, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + + // Tasks + audit.RegisterEventForAudit(func(e *TaskCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + + // Projects + audit.RegisterEventForAudit(func(e *ProjectCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventForAudit(func(e *ProjectUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventForAudit(func(e *ProjectDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithUserEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectSharedWithUser, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"user_id": e.User.ID}, + } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithTeamEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectSharedWithTeam, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"team_id": e.Team.ID}, + } + }) + + // Teams + audit.RegisterEventForAudit(func(e *TeamCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + } + }) + audit.RegisterEventForAudit(func(e *TeamDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + } + }) + audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TeamMemberRemovedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) } ////// diff --git a/pkg/models/models.go b/pkg/models/models.go index df562ef90..88d98231c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -71,6 +71,7 @@ func GetTables() []interface{} { &TaskUnreadStatus{}, &Session{}, &OAuthCode{}, + &TimeEntry{}, } } diff --git a/pkg/models/notifications_database.go b/pkg/models/notifications_database.go index aaa71103c..75beef580 100644 --- a/pkg/models/notifications_database.go +++ b/pkg/models/notifications_database.go @@ -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. diff --git a/pkg/models/notifications_refresh.go b/pkg/models/notifications_refresh.go new file mode 100644 index 000000000..cc119aeae --- /dev/null +++ b/pkg/models/notifications_refresh.go @@ -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 . + +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 + } +} diff --git a/pkg/models/notifications_refresh_test.go b/pkg/models/notifications_refresh_test.go new file mode 100644 index 000000000..b0cef2a43 --- /dev/null +++ b/pkg/models/notifications_refresh_test.go @@ -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 . + +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 +} diff --git a/pkg/models/project.go b/pkg/models/project.go index 022f673a5..b0ad5ee4c 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -448,6 +448,20 @@ func GetProjectSimpleByID(s *xorm.Session, projectID int64) (project *Project, e return } +// GetProjectSimpleByIdentifier gets a project by its textual identifier (e.g. "PROJ"). +// Identifiers are stored uppercase, so the lookup normalizes the input. +func GetProjectSimpleByIdentifier(s *xorm.Session, identifier string) (project *Project, err error) { + project, exists, err := getProjectSimple(s, builder.Eq{"identifier": strings.ToUpper(identifier)}) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrProjectDoesNotExist{} + } + + return +} + func getProjectSimple(s *xorm.Session, cond builder.Cond) (project *Project, exists bool, err error) { project = &Project{} exists, err = s. @@ -1058,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 } @@ -1205,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) @@ -1436,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{} diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 2ad0857ec..6b7c2f87d 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -33,10 +33,12 @@ type ProjectDuplicate struct { // The project id of the project to duplicate ProjectID int64 `json:"-" param:"projectid"` // The target parent project - ParentProjectID int64 `json:"parent_project_id,omitempty"` + ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."` + // 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"` + Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."` web.Permissions `json:"-"` web.CRUDable `json:"-"` @@ -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 diff --git a/pkg/models/project_duplicate_test.go b/pkg/models/project_duplicate_test.go index a89342edb..5e3360a1b 100644 --- a/pkg/models/project_duplicate_test.go +++ b/pkg/models/project_duplicate_test.go @@ -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) diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 0c9fb6908..e6fd75f96 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -112,7 +112,7 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: a, + Doer: doerFromAuth(s, a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 58ef71c38..0789220ce 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -118,7 +118,7 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: a, + Doer: doerFromAuth(s, a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/reaction.go b/pkg/models/reaction.go index 712e144a2..9567cfbc9 100644 --- a/pkg/models/reaction.go +++ b/pkg/models/reaction.go @@ -38,7 +38,7 @@ type Reaction struct { ID int64 `xorm:"autoincr not null unique pk" json:"-" param:"reaction"` // The user who reacted - User *user.User `xorm:"-" json:"user" valid:"-"` + User *user.User `xorm:"-" json:"user" valid:"-" readOnly:"true" doc:"The user who reacted. Set by the server from the authenticated user; ignored on write."` UserID int64 `xorm:"bigint not null INDEX" json:"-"` // The id of the entity you're reacting to @@ -48,10 +48,10 @@ type Reaction struct { EntityKindString string `xorm:"-" json:"-" param:"entitykind"` // The actual reaction. This can be any valid utf character or text, up to a length of 20. - Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required"` + Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required" maxLength:"20" doc:"The reaction itself: any UTF text up to 20 characters, e.g. an emoji."` // A timestamp when this reaction was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this reaction was created. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` diff --git a/pkg/models/saved_filter_positions_test.go b/pkg/models/saved_filter_positions_test.go index d233460d2..91bf09d9c 100644 --- a/pkg/models/saved_filter_positions_test.go +++ b/pkg/models/saved_filter_positions_test.go @@ -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) diff --git a/pkg/models/sessions.go b/pkg/models/sessions.go index 9c7a5d1f1..9824cf0c3 100644 --- a/pkg/models/sessions.go +++ b/pkg/models/sessions.go @@ -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 { diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go index 66948fc84..15b056faa 100644 --- a/pkg/models/setup_tests.go +++ b/pkg/models/setup_tests.go @@ -59,6 +59,7 @@ func SetupTests() { "task_relations", "task_reminders", "tasks", + "time_entries", "team_projects", "team_members", "teams", diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go index 6324c5223..662973e80 100644 --- a/pkg/models/task_assignees.go +++ b/pkg/models/task_assignees.go @@ -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 @@ -346,7 +346,7 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page // BulkAssignees is a helper struct used to update multiple assignees at once. type BulkAssignees struct { // A project with all assignees - Assignees []*user.User `json:"assignees"` + Assignees []*user.User `json:"assignees" doc:"The full set of users to assign to the task. This replaces the task's current assignees: users not in this list are unassigned. Pass an empty array to unassign everyone. Each user must have access to the task's project."` TaskID int64 `json:"-" param:"projecttask"` web.CRUDable `json:"-"` diff --git a/pkg/models/task_assignees_test.go b/pkg/models/task_assignees_test.go new file mode 100644 index 000000000..415913d1c --- /dev/null +++ b/pkg/models/task_assignees_test.go @@ -0,0 +1,80 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "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) +} diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index 59d8d7594..327e2311a 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -23,6 +23,7 @@ import ( "image/png" "io" "strconv" + "strings" "time" "code.vikunja.io/api/pkg/events" @@ -38,16 +39,16 @@ import ( // TaskAttachment is the definition of a task attachment type TaskAttachment struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment"` - TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment" readOnly:"true" doc:"The unique, numeric id of this attachment."` + TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the task this attachment belongs to. Taken from the URL, not the body."` FileID int64 `xorm:"bigint not null" json:"-"` CreatedByID int64 `xorm:"bigint not null" json:"-"` - CreatedBy *user.User `xorm:"-" json:"created_by"` + CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who uploaded this attachment."` - File *files.File `xorm:"-" json:"file"` + File *files.File `xorm:"-" json:"file" readOnly:"true" doc:"Metadata of the uploaded file (name, mime type, size). The bytes are fetched from the download endpoint, not this field."` - Created time.Time `xorm:"created" json:"created"` + Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this attachment was uploaded. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` @@ -106,6 +107,74 @@ func (ta *TaskAttachment) NewAttachment(s *xorm.Session, f io.ReadSeeker, realna return nil } +// AttachmentToUpload is a transport-neutral file to attach, so the upload logic +// can be shared by the multipart v1 handler and the Huma v2 handler. +type AttachmentToUpload struct { + Reader io.ReadSeeker + Filename string + Size uint64 +} + +// UploadTaskAttachments checks create access to the task, then stores each file, +// collecting per-file failures rather than aborting. The caller owns the session +// and the commit. A returned err means the request as a whole failed (e.g. +// forbidden); per-file failures come back in failures instead. +func UploadTaskAttachments(s *xorm.Session, a web.Auth, taskID int64, uploads []*AttachmentToUpload) (success []*TaskAttachment, failures []error, err error) { + ta := &TaskAttachment{TaskID: taskID} + can, err := ta.CanCreate(s, a) + if err != nil { + return nil, nil, err + } + if !can { + return nil, nil, ErrGenericForbidden{} + } + + for _, upload := range uploads { + attachment := &TaskAttachment{TaskID: taskID} + if err := attachment.NewAttachment(s, upload.Reader, upload.Filename, upload.Size, a); err != nil { + failures = append(failures, err) + continue + } + success = append(success, attachment) + } + return success, failures, nil +} + +// LoadTaskAttachmentForDownload checks read access, loads the attachment with its +// open file, and resolves a preview if previewSize is set and the file is an image. +// It returns the loaded attachment and, when applicable, the preview bytes (the +// caller serves those instead of the file). The caller owns the session, the +// commit, and writing the response. Returns ErrGenericForbidden on denied access. +func LoadTaskAttachmentForDownload(s *xorm.Session, a web.Auth, taskID, attachmentID int64, previewSize PreviewSize) (ta *TaskAttachment, preview []byte, err error) { + ta = &TaskAttachment{ID: attachmentID, TaskID: taskID} + can, _, err := ta.CanRead(s, a) + if err != nil { + return nil, nil, err + } + if !can { + return nil, nil, ErrGenericForbidden{} + } + + if err := ta.ReadOne(s, a); err != nil { + return nil, nil, err + } + if err := ta.File.LoadFileByID(); err != nil { + return nil, nil, err + } + + if previewSize != PreviewSizeUnknown && strings.HasPrefix(ta.File.Mime, "image") { + preview = ta.GetPreview(previewSize) + // GetPreview consumes the file reader; re-open it for the non-preview fallback. + if preview == nil { + if err := ta.File.LoadFileByID(); err != nil { + return nil, nil, err + } + } + } + + return ta, preview, nil +} + // ReadOne returns a task attachment func (ta *TaskAttachment) ReadOne(s *xorm.Session, _ web.Auth) (err error) { query := s.Where("id = ?", ta.ID).NoAutoCondition() diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 22f048564..bc217f7ca 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -58,6 +58,12 @@ type TaskCollection struct { isSavedFilter bool + // forceFlatTasks makes ReadAll always return []*Task, never []*Bucket, even + // for a kanban view. v1's single tasks endpoint is polymorphic; v2 splits it + // into a flat-tasks endpoint and a separate buckets-with-tasks one, and the + // former sets this so a kanban view path still yields tasks. + forceFlatTasks bool + web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` } @@ -69,6 +75,7 @@ const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets` const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions` const TaskCollectionExpandComments TaskCollectionExpandable = `comments` const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count` +const TaskCollectionExpandTimeEntriesCount TaskCollectionExpandable = `time_entries_count` const TaskCollectionExpandIsUnread TaskCollectionExpandable = `is_unread` // Validate validates if the TaskCollectionExpandable value is valid. @@ -84,11 +91,13 @@ func (t TaskCollectionExpandable) Validate() error { return nil case TaskCollectionExpandCommentCount: return nil + case TaskCollectionExpandTimeEntriesCount: + return nil case TaskCollectionExpandIsUnread: return nil } - return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, is_unread") + return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread") } func validateTaskField(fieldName string) error { @@ -146,8 +155,14 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie return opts, err } -func getTaskOrTasksInBuckets(s *xorm.Session, a web.Auth, projects []*Project, view *ProjectView, opts *taskSearchOptions, filteringForBucket bool) (tasks interface{}, resultCount int, totalItems int64, err error) { - if filteringForBucket { +// SetForceFlatTasks makes ReadAll return a flat []*Task even for a kanban view. +// The v2 tasks endpoint uses it; v1 leaves it unset for the polymorphic shape. +func (tf *TaskCollection) SetForceFlatTasks() { + tf.forceFlatTasks = true +} + +func getTaskOrTasksInBuckets(s *xorm.Session, a web.Auth, projects []*Project, view *ProjectView, opts *taskSearchOptions, filteringForBucket, forceFlatTasks bool) (tasks interface{}, resultCount int, totalItems int64, err error) { + if filteringForBucket || forceFlatTasks { return getTasksForProjects(s, projects, a, opts, view) } @@ -277,6 +292,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa tc.ProjectID = tf.ProjectID tc.isSavedFilter = true tc.Expand = tf.Expand + tc.forceFlatTasks = tf.forceFlatTasks if tf.Filter != "" { if tc.Filter != "" { @@ -369,7 +385,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa if err != nil { return nil, 0, 0, err } - return getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts, filteringForBucket) + return getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts, filteringForBucket, tf.forceFlatTasks) } projects, err := getRelevantProjectsFromCollection(s, a, tf) @@ -377,5 +393,5 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return nil, 0, 0, err } - return getTaskOrTasksInBuckets(s, a, projects, view, opts, filteringForBucket) + return getTaskOrTasksInBuckets(s, a, projects, view, opts, filteringForBucket, tf.forceFlatTasks) } diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go index 7d2970277..6828b0e07 100644 --- a/pkg/models/task_collection_filter.go +++ b/pkg/models/task_collection_filter.go @@ -170,20 +170,16 @@ func parseFilterFromExpression(f fexpr.ExprGroup, loc *time.Location) (filter *t return filter, nil } -func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) { - - if filter == "" { - return - } - +// preprocessFilterString rewrites the human filter syntax (in / not in / like) +// into fexpr sigils and quotes bare values so fexpr.Parse accepts them. Shared +// by every entity that filters with the task grammar. +func preprocessFilterString(filter string) string { filter = strings.ReplaceAll(filter, " not in ", " "+string(fexpr.SignAnyNeq)+" ") filter = strings.ReplaceAll(filter, " in ", " ?= ") filter = strings.ReplaceAll(filter, " like ", " ~ ") - // Regex pattern to match filter expressions re := regexp.MustCompile(`(\w+)\s*(>=|<=|!=|~|\?=|\?!=|=|>|<)\s*([^&|()]+)`) - - filter = re.ReplaceAllStringFunc(filter, func(match string) string { + return re.ReplaceAllStringFunc(filter, func(match string) string { parts := re.FindStringSubmatch(match) if len(parts) != 4 { return match @@ -193,16 +189,24 @@ func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filte comparator := parts[2] value := strings.TrimSpace(parts[3]) - // Check if the value is already quoted + // Already quoted — leave as-is if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) || (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) { return field + " " + comparator + " " + value } - // Quote the value quotedValue := "'" + strings.ReplaceAll(value, "'", "\\'") + "'" return field + " " + comparator + " " + quotedValue }) +} + +func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) { + + if filter == "" { + return + } + + filter = preprocessFilterString(filter) parsedFilter, err := fexpr.Parse(filter) if err != nil { diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go index 61f8f6dc4..988dc4f27 100644 --- a/pkg/models/task_comments_test.go +++ b/pkg/models/task_comments_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "fmt" "testing" @@ -45,7 +46,7 @@ func TestTaskComment_Create(t *testing.T) { assert.Equal(t, int64(1), tc.Author.ID) err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCommentCreatedEvent{}) db.AssertExists(t, "task_comments", map[string]interface{}{ diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 325033207..bcc09884b 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01 type TaskPosition struct { // The ID of the task this position is for - TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"` + TaskID int64 `xorm:"bigint not null index 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"` + 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). @@ -44,7 +44,7 @@ type TaskPosition struct { // which also leaves a lot of room for rearranging and sorting later. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. - Position float64 `xorm:"double not null" json:"position"` + Position float64 `xorm:"double not null" json:"position" doc:"The task's sort position within the view, as a float so a task can be placed between any two others. To drop a task between two neighbours, set this to their midpoint. Values below the minimum spacing trigger a server-side recalculation of all positions in the view, so the stored value may differ from what you sent."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` @@ -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. diff --git a/pkg/models/task_relation.go b/pkg/models/task_relation.go index 58280c5ee..392ea2ef4 100644 --- a/pkg/models/task_relation.go +++ b/pkg/models/task_relation.go @@ -81,18 +81,19 @@ type TaskRelation struct { // The unique, numeric id of this relation. ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"` // The ID of the "base" task, the task which has a relation to another. - TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"` + TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the base task. Set from the URL path; ignored in the request body."` // The ID of the other task, the task which is being related. - OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask"` + OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask" doc:"The id of the other task this relation points to."` // The kind of the relation. - RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind"` + // The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it. + RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of relation, describing the direction from the base task to the other task (e.g. subtask, blocking, related). The inverse relation is created automatically."` CreatedByID int64 `xorm:"bigint not null" json:"-"` // The user who created this relation - CreatedBy *user.User `xorm:"-" json:"created_by"` + CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this relation."` // A timestamp when this label was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this relation was created. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 236bc7bca..978a0f850 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -62,25 +62,25 @@ func validateRepeatAfter(repeatAfter int64) error { // Task represents a task in a project type Task struct { // The unique, numeric id of this task. - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask" readOnly:"true" doc:"The unique, numeric id of this task."` // The task text. This is what you'll see in the project. - Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1"` + Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1" doc:"The task title. This is what you'll see in the project."` // The task description. Description string `xorm:"longtext null" json:"description"` // Whether a task is done or not. Done bool `xorm:"INDEX null" json:"done"` // The time when a task was marked as done. This field is system-controlled and cannot be set via API. - DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at"` + DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at" readOnly:"true" doc:"When the task was marked as done. Set by the server; ignored on write."` // The time when the task is due. DueDate time.Time `xorm:"DATETIME INDEX null 'due_date'" json:"due_date"` // An array of reminders that are associated with this task. Reminders []*TaskReminder `xorm:"-" json:"reminders"` // The project this task belongs to. - ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project" doc:"The id of the project this task belongs to. On create it is taken from the URL; on update, setting it to a different project moves the task (requires write access to the target project)."` // An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as "undone" and then increase all remindes and the due date by its amount. - RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)"` + RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)" doc:"The interval in seconds this task repeats. When set, marking the task done re-opens it and bumps its reminders and due date by this amount."` // Can have three possible values which will trigger when the task is marked as done: 0 = repeats after the amount specified in repeat_after, 1 = repeats all dates each months (ignoring repeat_after), 3 = repeats from the current date rather than the last set date. - RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode"` + RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode" doc:"How the task repeats when marked done: 0 = after repeat_after seconds, 1 = monthly (ignores repeat_after), 2 = from the current date rather than the last set date."` // The task priority. Can be anything you want, it is possible to sort by this later. Priority int64 `xorm:"bigint null" json:"priority"` // When this task starts. @@ -88,57 +88,60 @@ type Task struct { // When this task ends. EndDate time.Time `xorm:"DATETIME INDEX null 'end_date'" json:"end_date" query:"-"` // An array of users who are assigned to this task - Assignees []*user.User `xorm:"-" json:"assignees"` + Assignees []*user.User `xorm:"-" json:"assignees" readOnly:"true" doc:"The users assigned to this task. Read-only here; use the task-assignee endpoints to change assignments."` // An array of labels which are associated with this task. This property is read-only, you must use the separate endpoint to add labels to a task. - Labels []*Label `xorm:"-" json:"labels"` + Labels []*Label `xorm:"-" json:"labels" readOnly:"true" doc:"The labels on this task. Read-only here; use the label-task endpoints to add or remove labels."` // The task color in hex - HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"` + HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The task color as a hex string without the leading '#'."` // Determines how far a task is left from being done - PercentDone float64 `xorm:"DOUBLE null" json:"percent_done"` + PercentDone float64 `xorm:"DOUBLE null" json:"percent_done" doc:"How far the task is from done, between 0 and 1."` // The task identifier, based on the project identifier and the task's index - Identifier string `xorm:"-" json:"identifier"` + Identifier string `xorm:"-" json:"identifier" readOnly:"true" doc:"The textual task identifier, derived from the project identifier and the task index (e.g. \"PROJ-12\")."` // The task index, calculated per project - Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index"` + Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index" readOnly:"true" doc:"The per-project task index, assigned by the server."` // The UID is currently not used for anything other than CalDAV, which is why we don't expose it over json UID string `xorm:"varchar(250) null" json:"-"` // All related tasks, grouped by their relation kind - RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks"` + RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks" readOnly:"true" doc:"Related tasks grouped by relation kind. Read-only here; use the task-relation endpoints to change relations."` // All attachments this task has. This property is read-onlym, you must use the separate endpoint to add attachments to a task. - Attachments []*TaskAttachment `xorm:"-" json:"attachments"` + Attachments []*TaskAttachment `xorm:"-" json:"attachments" readOnly:"true" doc:"The task's attachments. Read-only here; use the attachment endpoints to add or remove them."` // If this task has a cover image, the field will return the id of the attachment that is the cover image. - CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id"` + CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id" doc:"The id of the attachment used as this task's cover image, or 0 for none."` // True if a task is a favorite task. Favorite tasks show up in a separate "Important" project. This value depends on the user making the call to the api. - IsFavorite bool `xorm:"-" json:"is_favorite"` + IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the requesting user has favorited this task. Per-user, so it differs between callers."` - IsUnread *bool `xorm:"-" json:"is_unread,omitempty"` + IsUnread *bool `xorm:"-" json:"is_unread,omitempty" readOnly:"true" doc:"Whether the task is unread for the requesting user. Only present when requested via the is_unread expand option."` // The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it. // Will only returned when retrieving one task. - Subscription *Subscription `xorm:"-" json:"subscription,omitempty"` + Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription to this task. Read-only here; use the subscription endpoints to change it. Only present when reading a single task."` // A timestamp when this task was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"When this task was created. Set by the server; ignored on write."` // A timestamp when this task was last updated. You cannot change this value. - Updated time.Time `xorm:"updated not null" json:"updated"` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"When this task was last updated. Set by the server; ignored on write."` // The bucket id. Will only be populated when the task is accessed via a view with buckets. // Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. - BucketID int64 `xorm:"-" json:"bucket_id"` + BucketID int64 `xorm:"-" json:"bucket_id" doc:"The bucket the task is in. Only populated when the task is accessed via a view with buckets. To move a task between buckets, the new bucket must be in the same view as the old one."` // All buckets across all views this task is part of. Only present when fetching tasks with the `expand` parameter set to `buckets`. - Buckets []*Bucket `xorm:"-" json:"buckets,omitempty"` + Buckets []*Bucket `xorm:"-" json:"buckets,omitempty" readOnly:"true" doc:"The task's buckets across all views. Only present when requested via the buckets expand option."` // All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`. - Comments []*TaskComment `xorm:"-" json:"comments,omitempty"` + Comments []*TaskComment `xorm:"-" json:"comments,omitempty" readOnly:"true" doc:"The task's first 50 comments. Only present when requested via the comments expand option."` // Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`. - CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"` + CommentCount *int64 `xorm:"-" json:"comment_count,omitempty" readOnly:"true" doc:"The number of comments on this task. Only present when requested via the comment_count expand option."` + + // Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`. + TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty" readOnly:"true" doc:"The number of time entries on this task. Only present when requested via the time_entries_count expand option."` // Behaves exactly the same as with the TaskCollection.Expand parameter Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"` @@ -147,13 +150,13 @@ type Task struct { // When accessing tasks via views with buckets, this is primarily used to sort them based on a range. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. - Position float64 `xorm:"-" json:"position"` + Position float64 `xorm:"-" json:"position" readOnly:"true" doc:"The task's position, saved per view. Only non-zero when the task is fetched through a view endpoint; use the task-position endpoint to change it."` // Reactions on that task. - Reactions ReactionMap `xorm:"-" json:"reactions"` + Reactions ReactionMap `xorm:"-" json:"reactions" readOnly:"true" doc:"Reactions on this task. Only present when requested via the reactions expand option."` // The user who initially created the task. - CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"` + CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-" readOnly:"true" doc:"The user who created this task. Set by the server."` CreatedByID int64 `xorm:"bigint not null" json:"-"` // ID of the user who put that task on the project web.CRUDable `xorm:"-" json:"-"` @@ -767,6 +770,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi if err != nil { return err } + case TaskCollectionExpandTimeEntriesCount: + err = addTimeEntriesCountToTasks(s, a, taskIDs, taskMap) + if err != nil { + return err + } case TaskCollectionExpandIsUnread: err = addIsUnreadToTasks(s, taskIDs, taskMap, a) if err != nil { @@ -822,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) @@ -970,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 { @@ -1441,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}) @@ -1727,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) { newTask.Done = false } +var ( + checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`) + checklistInputCheckedRegex = regexp.MustCompile(`(]*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: @@ -1746,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() } @@ -1940,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}) @@ -2011,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 } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index caa897740..433bee18a 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "testing" "time" @@ -70,7 +71,7 @@ func TestTask_Create(t *testing.T) { "bucket_id": 1, }, false) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCreatedEvent{}) }) t.Run("with reminders", func(t *testing.T) { @@ -280,7 +281,7 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Verify exactly ONE task.updated event was dispatched count := events.CountDispatchedEvents("task.updated") assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count) @@ -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
  • Item

after` + const unchecked = `before
  • Item

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
  • Item

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) + }) }) } diff --git a/pkg/models/team_members.go b/pkg/models/team_members.go index ac2654214..b31929277 100644 --- a/pkg/models/team_members.go +++ b/pkg/models/team_members.go @@ -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 } diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 98c87161c..ab0a80846 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -222,7 +222,7 @@ func (t *Team) CreateNewTeam(s *xorm.Session, a web.Auth, firstUserShouldBeAdmin events.DispatchOnCommit(s, &TeamCreatedEvent{ Team: t, - Doer: a, + Doer: doer, }) return nil } @@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: a, + Doer: doerFromAuth(s, a), }) return nil } diff --git a/pkg/models/time_tracking.go b/pkg/models/time_tracking.go new file mode 100644 index 000000000..fc9ee3251 --- /dev/null +++ b/pkg/models/time_tracking.go @@ -0,0 +1,441 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + "xorm.io/builder" + "xorm.io/xorm" +) + +// TimeEntry is a single tracked time span attached to either a task or a +// project — exactly one of TaskID / ProjectID is set (XOR). A running live +// timer is just an entry whose EndTime is still null. +// +// v2-only: doc: tags are the schema's source of truth (no v1 swaggo), and it +// implements CRUDable + Permissions because the shared handler.Do* pipeline needs them. +type TimeEntry struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"timeentry" readOnly:"true" doc:"The unique, numeric id of this time entry."` + + UserID int64 `xorm:"bigint not null INDEX" json:"user_id" readOnly:"true" doc:"The id of the user who logged this time entry. Set by the server."` + + TaskID int64 `xorm:"bigint null INDEX" json:"task_id" doc:"The task this entry is attached to. Exactly one of task_id / project_id must be set."` + ProjectID int64 `xorm:"bigint null INDEX" json:"project_id" doc:"The project this entry is attached to directly. Exactly one of task_id / project_id must be set."` + + StartTime time.Time `xorm:"not null INDEX" json:"start_time" doc:"When the tracked time started."` + EndTime *time.Time `xorm:"null" json:"end_time" doc:"When the tracked time ended. Null means a live timer is still running."` + + Comment string `xorm:"text null" json:"comment" doc:"An optional comment describing the logged time."` + + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this time entry was created. You cannot change this value."` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this time entry was last updated. You cannot change this value."` + + // Filter-only fields (not persisted): set by the v2 list route, read by ReadAll. + Filter string `xorm:"-" json:"-"` + FilterTimezone string `xorm:"-" json:"-"` + + web.CRUDable `xorm:"-" json:"-"` + web.Permissions `xorm:"-" json:"-"` +} + +// TableName is time_entries, not the xorm-default time_entry. +func (*TimeEntry) TableName() string { + return "time_entries" +} + +// --- CRUDable --- + +func (te *TimeEntry) Create(s *xorm.Session, a web.Auth) (err error) { + te.UserID = a.GetID() + + // Starting a new running timer auto-stops the previous one; a completed + // manual entry (EndTime set) must leave the running timer alone. + if te.EndTime == nil { + if _, err = stopRunningTimerForUser(s, te.UserID); err != nil { + return err + } + } + + if te.StartTime.IsZero() { + te.StartTime = time.Now() + } + + if err = te.validateTimes(); err != nil { + return err + } + + if _, err = s.Insert(te); err != nil { + return err + } + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryCreatedEvent{TimeEntry: te, Doer: doer}) + return nil +} + +func (te *TimeEntry) ReadOne(_ *xorm.Session, _ web.Auth) (err error) { + // entry got already fetched in CanRead, nothing left to do here + return nil +} + +// stopRunningTimerForUser stops the user's active timer (end_time = now) and +// returns it, or nil if no timer is running. +func stopRunningTimerForUser(s *xorm.Session, userID int64) (*TimeEntry, error) { + running := &TimeEntry{} + exists, err := s.Where("user_id = ? AND end_time IS NULL", userID).Get(running) + if err != nil { + return nil, err + } + if !exists { + return nil, nil + } + if err := running.stop(s); err != nil { + return nil, err + } + + doer, err := user.GetUserByID(s, userID) + if err != nil { + return nil, err + } + events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: running, Doer: doer}) + return running, nil +} + +// StopRunningTimer stops the authenticated user's active timer and returns it, +// or ErrNoRunningTimer when none is running. The stop time is the server's now. +func StopRunningTimer(s *xorm.Session, a web.Auth) (*TimeEntry, error) { + // Link shares have no time tracking (mirrors the Can* methods). Their id is a + // share id, not a user id, so without this a share whose id collides with a + // user's would stop and read that user's running timer. + if _, isShare := a.(*LinkSharing); isShare { + return nil, ErrGenericForbidden{} + } + + running, err := stopRunningTimerForUser(s, a.GetID()) + if err != nil { + return nil, err + } + if running == nil { + return nil, ErrNoRunningTimer{UserID: a.GetID()} + } + return running, nil +} + +// readableTimeEntriesCond restricts a query to entries the auth can read: a +// standalone entry on an accessible project, or one on a task in such a project. +func readableTimeEntriesCond(a web.Auth) builder.Cond { + return entriesForProjectCond(accessibleProjectIDsSubquery(a, "project_id")) +} + +func (te *TimeEntry) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) { + // Link shares have no time-tracking access (mirrors the Can* methods); + // DoReadAll skips the permission check, so it must be guarded here too. + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return []*TimeEntry{}, 0, 0, nil + } + + cond := readableTimeEntriesCond(a) + if te.TaskID > 0 { + cond = cond.And(builder.Eq{"task_id": te.TaskID}) + } + if te.ProjectID > 0 { + cond = cond.And(entriesForProjectCond(builder.Eq{"project_id": te.ProjectID})) + } + + filterCond, err := timeEntryFilterCond(te.Filter, te.FilterTimezone) + if err != nil { + return nil, 0, 0, err + } + if filterCond != nil { + cond = cond.And(filterCond) + } + + if search != "" { + cond = cond.And(db.MultiFieldSearch([]string{"comment"}, search)) + } + + total, err := s.Where(cond). + Count(&TimeEntry{}) + if err != nil { + return nil, 0, 0, err + } + + entries := []*TimeEntry{} + err = s.Where(cond). + OrderBy("start_time ASC"). + Limit(getLimitFromPageIndex(page, perPage)). + Find(&entries) + return entries, len(entries), total, err +} + +func (te *TimeEntry) Update(s *xorm.Session, a web.Auth) (err error) { + // A completed entry can't be reopened into a running timer via update — that + // would sidestep Create's single-active-timer rule; start a new one instead. + existing, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + if existing.EndTime != nil && te.EndTime == nil { + return ErrTimeEntryAlreadyEnded{TimeEntryID: te.ID} + } + + if err = te.validateTimes(); err != nil { + return err + } + + // task_id / project_id are listed so a reassignment (and the zero value of + // the side being cleared) is written; the XOR was validated in CanUpdate. + _, err = s. + Where("id = ?", te.ID). + Cols("task_id", "project_id", "start_time", "end_time", "comment"). + Update(te) + if err != nil { + return err + } + + // reload: Update wrote only the editable columns + updated, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + *te = *updated + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: te, Doer: doer}) + return nil +} + +func (te *TimeEntry) Delete(s *xorm.Session, a web.Auth) (err error) { + entry, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + if _, err = s.Where("id = ?", te.ID).Delete(&TimeEntry{}); err != nil { + return err + } + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryDeletedEvent{TimeEntry: entry, Doer: doer}) + return nil +} + +func getTimeEntryByID(s *xorm.Session, id int64) (*TimeEntry, error) { + entry := &TimeEntry{} + exists, err := s.Where("id = ?", id).Get(entry) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrTimeEntryDoesNotExist{TimeEntryID: id} + } + return entry, nil +} + +func (te *TimeEntry) stop(s *xorm.Session) (err error) { + now := time.Now() + te.EndTime = &now + _, err = s.ID(te.ID).Update(te) + return err +} + +// --- Permissions --- + +// Returns the loaded entry rather than mutating te, so Update keeps its payload. +func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) { + entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID} + if fetch { + var err error + entry, err = getTimeEntryByID(s, te.ID) + if err != nil { + return nil, false, -1, err + } + } + + switch { + case entry.TaskID != 0: + task, err := GetTaskByIDSimple(s, entry.TaskID) + if err != nil { + return entry, false, -1, err + } + can, maxPerm, err := task.CanRead(s, a) + return entry, can, maxPerm, err + case entry.ProjectID != 0: + project, _, err := getProjectSimple(s, builder.Eq{"id": entry.ProjectID}) + if err != nil { + return entry, false, -1, err + } + can, maxPerm, err := project.CanRead(s, a) + return entry, can, maxPerm, err + default: + return entry, false, 0, nil + } +} + +func (te *TimeEntry) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, 0, nil + } + + entry, can, maxPerm, err := te.canDoTimeEntry(s, a, true) + if err != nil { + return false, maxPerm, err + } + *te = *entry // ReadOne is a no-op; populate te here + return can, maxPerm, nil +} + +// validateContainer enforces the XOR invariant: exactly one of task or project. +func (te *TimeEntry) validateContainer() error { + if (te.TaskID == 0) == (te.ProjectID == 0) { + return ErrTimeEntryInvalidContainer{TaskID: te.TaskID, ProjectID: te.ProjectID} + } + return nil +} + +// validateTimes rejects a completed entry whose end precedes its start (a +// negative interval). A null end is a running timer and is always valid; an end +// equal to the start is allowed (a zero-length entry). +func (te *TimeEntry) validateTimes() error { + if te.EndTime != nil && te.EndTime.Before(te.StartTime) { + return ErrTimeEntryEndBeforeStart{TimeEntryID: te.ID} + } + return nil +} + +func (te *TimeEntry) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + if err := te.validateContainer(); err != nil { + return false, err + } + + _, can, _, err := te.canDoTimeEntry(s, a, false) + return can, err +} + +// CanUpdate allows the author to edit their entry, including moving it between +// task / project: on top of the author check it validates the (possibly new) +// container (XOR) and requires read access to it, mirroring create. +func (te *TimeEntry) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + existing, err := getTimeEntryByID(s, te.ID) + if err != nil { + return false, err + } + if existing.UserID != a.GetID() { + return false, nil + } + + // A request that omits the container keeps the existing one — an entry + // always has exactly one, so "clearing" it is never valid. + if te.TaskID == 0 && te.ProjectID == 0 { + te.TaskID = existing.TaskID + te.ProjectID = existing.ProjectID + } + if err := te.validateContainer(); err != nil { + return false, err + } + + _, canReadContainer, _, err := te.canDoTimeEntry(s, a, false) + return canReadContainer, err +} + +func (te *TimeEntry) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + return te.canModify(s, a) +} + +// canModify gates delete: read access to the container plus being the author. +func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + entry, canRead, _, err := te.canDoTimeEntry(s, a, true) + if err != nil { + return false, err + } + if !canRead { + return false, nil + } + return entry.UserID == a.GetID(), nil +} + +// addTimeEntriesCountToTasks attaches each task's time-entry count for the +// `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the +// same gates as the time-entry endpoints: the count is left unset (absent) for +// link shares or when the feature is unlicensed, so it can't leak that way. +func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error { + if _, isShare := a.(*LinkSharing); isShare { + return nil + } + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + return nil + } + if len(taskIDs) == 0 { + return nil + } + + zero := int64(0) + for _, taskID := range taskIDs { + if task, ok := taskMap[taskID]; ok { + task.TimeEntriesCount = &zero + } + } + + type timeEntriesCount struct { + TaskID int64 `xorm:"task_id"` + Count int64 `xorm:"count"` + } + + counts := []timeEntriesCount{} + if err := s. + Select("task_id, COUNT(*) as count"). + Where(builder.In("task_id", taskIDs)). + GroupBy("task_id"). + Table("time_entries"). + Find(&counts); err != nil { + return err + } + + for _, c := range counts { + if task, ok := taskMap[c.TaskID]; ok { + task.TimeEntriesCount = &c.Count + } + } + + return nil +} diff --git a/pkg/models/time_tracking_filter.go b/pkg/models/time_tracking_filter.go new file mode 100644 index 000000000..ec4c7b68e --- /dev/null +++ b/pkg/models/time_tracking_filter.go @@ -0,0 +1,204 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "strconv" + "strings" + "time" + + "code.vikunja.io/api/pkg/config" + + "github.com/ganigeorgiev/fexpr" + "github.com/jszwedko/go-datemath" + "xorm.io/builder" +) + +// entriesForProjectCond matches time entries belonging to a project given a +// predicate over a project_id column: standalone entries whose own project_id +// matches, plus task-attached entries whose task currently lives in a matching +// project. Tasks move between projects, so the project is resolved via the task +// at query time rather than denormalized. Used for both permission scoping and +// the project_id filter. +func entriesForProjectCond(projectIDCond builder.Cond) builder.Cond { + return builder.Or( + projectIDCond, + builder.In("task_id", + builder.Select("id").From("tasks").Where(projectIDCond), + ), + ) +} + +// timeEntryFilterCond parses a task-style filter string into a condition over +// the time_entries table, or nil for an empty filter. Filterable fields: +// user_id, task_id, project_id (ints / in-lists), start_time, end_time (dates, +// datemath, or the literal null for running timers). comment is deliberately +// not filterable — text matching belongs to search. +func timeEntryFilterCond(filter, filterTimezone string) (builder.Cond, error) { + if filter == "" { + return nil, nil + } + + parsed, err := fexpr.Parse(preprocessFilterString(filter)) + if err != nil { + return nil, &ErrInvalidFilterExpression{Expression: filter, ExpressionError: err} + } + + loc := config.GetTimeZone() + if filterTimezone != "" { + loc, err = time.LoadLocation(filterTimezone) + if err != nil { + return nil, &ErrInvalidTimezone{Name: filterTimezone, LoadError: err} + } + } + + return buildTimeEntryFilterCond(parsed, loc) +} + +func buildTimeEntryFilterCond(groups []fexpr.ExprGroup, loc *time.Location) (builder.Cond, error) { + conds := make([]builder.Cond, 0, len(groups)) + joins := make([]taskFilterConcatinator, 0, len(groups)) + + for _, g := range groups { + join := filterConcatAnd + if g.Join == fexpr.JoinOr { + join = filterConcatOr + } + + var ( + cond builder.Cond + err error + ) + switch item := g.Item.(type) { + case []fexpr.ExprGroup: // a parenthesized sub-expression + cond, err = buildTimeEntryFilterCond(item, loc) + case fexpr.Expr: + var comparator taskFilterComparator + comparator, err = getFilterComparatorFromOp(item.Op) + if err == nil { + cond, err = resolveTimeEntryFilter(item.Left.Literal, comparator, item.Right.Literal, loc) + } + } + if err != nil { + return nil, err + } + conds = append(conds, cond) + joins = append(joins, join) + } + + if len(conds) == 0 { + return nil, nil + } + result := conds[0] + for i := 1; i < len(conds); i++ { + if joins[i] == filterConcatOr { + result = builder.Or(result, conds[i]) + continue + } + result = builder.And(result, conds[i]) + } + return result, nil +} + +func resolveTimeEntryFilter(field string, comparator taskFilterComparator, raw string, loc *time.Location) (builder.Cond, error) { + switch field { + case "user_id", "task_id": + value, err := timeEntryIntFilterValue(raw, comparator) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw} + } + return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator, isNumeric: true}, false) + + case "project", "project_id": + value, err := timeEntryIntFilterValue(raw, comparator) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: "project_id", Value: raw} + } + // Build membership positively (standalone-in-project OR task-in-project) + // and negate the whole set for != / not in. Negating project_id alone would + // wrongly match task-attached entries, whose own project_id is 0. + positive, negate := comparator, false + if comparator == taskFilterComparatorNotEquals { + positive, negate = taskFilterComparatorEquals, true + } + if comparator == taskFilterComparatorNotIn { + positive, negate = taskFilterComparatorIn, true + } + inner, err := getFilterCond(&taskFilter{field: "project_id", value: value, comparator: positive, isNumeric: true}, false) + if err != nil { + return nil, err + } + cond := entriesForProjectCond(inner) + if negate { + cond = builder.Not{cond} + } + return cond, nil + + case "start_time", "end_time": + if raw == "null" { + return nullTimeFilterCond(field, comparator) + } + value, err := timeEntryTimeFilterValue(raw, loc) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw} + } + return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator}, false) + + default: + return nil, ErrInvalidTimeEntryFilterField{Field: field} + } +} + +// nullTimeFilterCond handles `end_time = null` (running timers) and its negation. +func nullTimeFilterCond(field string, comparator taskFilterComparator) (builder.Cond, error) { + if comparator == taskFilterComparatorEquals { + return &builder.IsNull{field}, nil + } + if comparator == taskFilterComparatorNotEquals { + return &builder.NotNull{field}, nil + } + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: "null"} +} + +func timeEntryIntFilterValue(raw string, comparator taskFilterComparator) (any, error) { + if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn { + parts := strings.Split(raw, ",") + values := make([]int64, 0, len(parts)) + for _, part := range parts { + v, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil { + return nil, err + } + values = append(values, v) + } + return values, nil + } + return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) +} + +// timeEntryTimeFilterValue mirrors the task filter's date handling: datemath +// (now, now-7d) first, then explicit date formats. +func timeEntryTimeFilterValue(raw string, loc *time.Location) (time.Time, error) { + if loc == nil { + loc = config.GetTimeZone() + } + if expr, err := safeDatemathParse(raw); err == nil { + t := expr.Time(datemath.WithLocation(loc)).In(config.GetTimeZone()) + return adjustDateForMysql(t), nil + } + return parseTimeFromUserInput(raw, loc) +} diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go new file mode 100644 index 000000000..6e5391d51 --- /dev/null +++ b/pkg/models/time_tracking_test.go @@ -0,0 +1,730 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "context" + "encoding/json" + "testing" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timePtr(t time.Time) *time.Time { return &t } + +// Fixture access graph (pkg/db/fixtures): project 1 is owned by user1 only +// (everyone else a stranger); task 1 lives in project 1. Project 3 is owned by +// user3, with user1 and user2 granted read. user4 has access to neither. +// Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3. + +func TestTimeEntry_CanRead(t *testing.T) { + tests := []struct { + name string + entryID int64 + auth web.Auth + wantCan bool + wantErr func(error) bool + }{ + {"owner reads task entry", 1, &user.User{ID: 1}, true, nil}, + {"owner reads project entry", 2, &user.User{ID: 1}, true, nil}, + {"reader reads other user's entry on a shared project", 3, &user.User{ID: 1}, true, nil}, + {"stranger denied on owned project", 1, &user.User{ID: 4}, false, nil}, + {"stranger denied on shared project", 3, &user.User{ID: 4}, false, nil}, + {"link share denied", 1, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, + {"missing entry is a 404", 999, &user.User{ID: 1}, false, IsErrTimeEntryDoesNotExist}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + can, _, err := (&TimeEntry{ID: tt.entryID}).CanRead(s, tt.auth) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCan, can) + }) + } +} + +func TestTimeEntry_CanCreate(t *testing.T) { + tests := []struct { + name string + entry *TimeEntry + auth web.Auth + wantCan bool + wantErr func(error) bool + }{ + {"on a task in an owned project", &TimeEntry{TaskID: 1}, &user.User{ID: 1}, true, nil}, + {"on an owned project", &TimeEntry{ProjectID: 1}, &user.User{ID: 1}, true, nil}, + {"on a readable project", &TimeEntry{ProjectID: 3}, &user.User{ID: 1}, true, nil}, + {"stranger denied", &TimeEntry{ProjectID: 1}, &user.User{ID: 4}, false, nil}, + {"both task and project is invalid", &TimeEntry{TaskID: 1, ProjectID: 1}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, + {"neither task nor project is invalid", &TimeEntry{}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, + {"link share denied", &TimeEntry{ProjectID: 1}, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + can, err := tt.entry.CanCreate(s, tt.auth) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCan, can) + }) + } +} + +// Entry 3 is authored by user3; user1 can read project 3 but is not the author, +// so it can read but not modify. +func TestTimeEntry_CanModify(t *testing.T) { + tests := []struct { + name string + entryID int64 + auth web.Auth + wantCan bool + }{ + {"author modifies own entry", 1, &user.User{ID: 1}, true}, + {"author modifies own entry on shared project", 3, &user.User{ID: 3}, true}, + {"reader who is not author cannot modify", 3, &user.User{ID: 1}, false}, + {"stranger cannot modify", 3, &user.User{ID: 4}, false}, + {"link share cannot modify", 1, &LinkSharing{ID: 1, ProjectID: 1}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + canUpdate, err := (&TimeEntry{ID: tt.entryID}).CanUpdate(s, tt.auth) + require.NoError(t, err) + assert.Equal(t, tt.wantCan, canUpdate, "CanUpdate") + + canDelete, err := (&TimeEntry{ID: tt.entryID}).CanDelete(s, tt.auth) + require.NoError(t, err) + assert.Equal(t, tt.wantCan, canDelete, "CanDelete") + }) + } +} + +// Guards the data leak: ReadAll must return only entries on tasks/projects the +// caller can read, since DoReadAll runs no permission check. +func TestTimeEntry_ReadAll(t *testing.T) { + tests := []struct { + name string + auth web.Auth + wantIDs []int64 + }{ + {"user sees every readable entry", &user.User{ID: 1}, []int64{1, 2, 3, 4}}, + {"user sees only entries on projects they can read", &user.User{ID: 2}, []int64{3}}, + {"stranger sees nothing", &user.User{ID: 4}, []int64{}}, + {"link share sees nothing", &LinkSharing{ID: 1, ProjectID: 1}, []int64{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + result, count, total, err := (&TimeEntry{}).ReadAll(s, tt.auth, "", 1, 50) + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + assert.Equal(t, len(tt.wantIDs), count) + assert.Equal(t, int64(len(tt.wantIDs)), total) + }) + } +} + +// Filtering reuses the task filter grammar. user1 can read entries 1,2,4 +// (project 1) and 3 (project 3, shared) — the filter only narrows that set. +func TestTimeEntry_ReadAll_Filter(t *testing.T) { + tests := []struct { + name string + filter string + wantIDs []int64 + wantErr bool + }{ + {"by user", "user_id = 3", []int64{3}, false}, + {"by task", "task_id = 1", []int64{1, 4}, false}, + {"by project unions task-attached entries", "project_id = 1", []int64{1, 2, 4}, false}, + {"by project negated", "project_id != 1", []int64{3}, false}, + {"by start time", "start_time > '2018-12-01T11:00:00+00:00'", []int64{2, 3, 4}, false}, + {"running timers via null end_time", "end_time = null", []int64{4}, false}, + {"compound and", "user_id = 1 && end_time = null", []int64{4}, false}, + {"compound or", "user_id = 3 || task_id = 1", []int64{1, 3, 4}, false}, + {"in list", "user_id in 1,3", []int64{1, 2, 3, 4}, false}, + {"comment is not filterable", "comment = whatever", nil, true}, + {"unknown field errors", "bogus = 1", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{Filter: tt.filter} + result, _, _, err := te.ReadAll(s, &user.User{ID: 1}, "", 1, 50) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + }) + } +} + +// Search matches the entry comment. Comments: 1="Time entry on task 1", +// 2/3 contain "Standalone", 4="Running timer". +func TestTimeEntry_ReadAll_Search(t *testing.T) { + tests := []struct { + name string + search string + wantIDs []int64 + }{ + {"matches a comment", "Running", []int64{4}}, + {"is case-insensitive", "running", []int64{4}}, + {"matches several", "Standalone", []int64{2, 3}}, + {"no match", "nothing matches this", []int64{}}, + {"empty search returns all readable", "", []int64{1, 2, 3, 4}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + result, _, _, err := (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, tt.search, 1, 50) + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + }) + } +} + +func TestTimeEntry_Create(t *testing.T) { + t.Run("manual entry keeps its start time and is owned by the caller", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + start := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC) + te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: &end, Comment: "work"} + require.NoError(t, te.Create(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + + assert.Equal(t, int64(1), te.UserID) + assert.True(t, te.StartTime.Equal(start)) + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": te.ID, + "user_id": 1, + "task_id": 1, + "comment": "work", + }, false) + }) + + t.Run("defaults the start time to now when none is given", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1} + require.NoError(t, te.Create(s, &user.User{ID: 1})) + assert.False(t, te.StartTime.IsZero()) + }) + + t.Run("a completed manual entry leaves a running timer alone", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // entry 4 is user1's running timer + manual := &TimeEntry{ + TaskID: 1, + StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), + } + require.NoError(t, manual.Create(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + + running := &TimeEntry{} + exists, err := s.Where("id = ?", 4).Get(running) + require.NoError(t, err) + require.True(t, exists) + assert.Nil(t, running.EndTime, "a manual entry must not stop the running timer") + }) + + t.Run("auto-stops the caller's running timer", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + first := &TimeEntry{TaskID: 1} + require.NoError(t, first.Create(s, a)) + require.Nil(t, first.EndTime, "first timer should be running") + + second := &TimeEntry{TaskID: 1} + require.NoError(t, second.Create(s, a)) + require.NoError(t, s.Commit()) + + reloaded := &TimeEntry{} + exists, err := s.Where("id = ?", first.ID).Get(reloaded) + require.NoError(t, err) + require.True(t, exists) + assert.NotNil(t, reloaded.EndTime, "first timer should have been auto-stopped") + assert.Nil(t, second.EndTime, "second timer should still be running") + }) +} + +// A running timer (no end) must round-trip as a NULL end_time: found by the +// null filter and serialized as JSON null, never the 0001-01-01 zero sentinel. +func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + te := &TimeEntry{TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)} + require.NoError(t, te.Create(s, a)) + require.NoError(t, s.Commit()) + + reloaded, err := getTimeEntryByID(s, te.ID) + require.NoError(t, err) + + marshalled, err := json.Marshal(reloaded) + require.NoError(t, err) + assert.Contains(t, string(marshalled), `"end_time":null`) + assert.NotContains(t, string(marshalled), "0001-01-01") + + // Stored as NULL, so the null filter matches it (not just the fixtures). + found := &TimeEntry{Filter: "end_time = null"} + result, _, _, err := found.ReadAll(s, a, "", 1, 50) + require.NoError(t, err) + ids := []int64{} + for _, e := range result.([]*TimeEntry) { + ids = append(ids, e.ID) + } + assert.Contains(t, ids, te.ID) +} + +// Regression guard: the permission check must not clobber the update payload. +func TestTimeEntry_Update(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + te := &TimeEntry{ + ID: 1, + TaskID: 1, + StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), + Comment: "updated comment", + } + + can, err := te.CanUpdate(s, a) // the handler calls this before Update + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + assert.Equal(t, "updated comment", te.Comment) + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "comment": "updated comment", + }, false) +} + +func TestTimeEntry_UpdateReassignsContainer(t *testing.T) { + validTimes := func(te *TimeEntry) { + te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) + } + + t.Run("moves an entry from a task to a project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + // Entry 1 is on task 1; move it onto project 1 directly. + te := &TimeEntry{ID: 1, ProjectID: 1} + validTimes(te) + + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "task_id": 0, + "project_id": 1, + }, false) + }) + + t.Run("rejects an update that sets both task and project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := (&TimeEntry{ID: 1, TaskID: 1, ProjectID: 1}).CanUpdate(s, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrTimeEntryInvalidContainer(err)) + }) + + t.Run("an omitted container keeps the existing one", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + // Entry 1 is on task 1; update only the comment, no container set. + te := &TimeEntry{ID: 1, Comment: "kept on task"} + validTimes(te) + + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "task_id": 1, + "project_id": 0, + "comment": "kept on task", + }, false) + }) +} + +func TestTimeEntry_UpdateReopenGuard(t *testing.T) { + a := &user.User{ID: 1} + someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + + t.Run("rejects clearing the end of a completed entry", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Entry 1 is completed; a nil end would reopen it as a running timer. + te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart} // EndTime nil + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + + err = te.Update(s, a) + require.Error(t, err) + assert.True(t, IsErrTimeEntryAlreadyEnded(err), "unexpected error type: %v", err) + }) + + t.Run("allows editing a running entry while it stays running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Entry 4 is user1's running timer; keeping it running (nil end) is fine. + te := &TimeEntry{ID: 4, TaskID: 1, StartTime: someStart, Comment: "edited"} // EndTime nil + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + }) +} + +func TestTimeEntry_RejectsInvertedInterval(t *testing.T) { + a := &user.User{ID: 1} + start := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC) + before := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + + t.Run("create rejects an end before the start", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(before)} + err := te.Create(s, a) + require.Error(t, err) + assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err) + }) + + t.Run("create allows an end equal to the start", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(start)} + require.NoError(t, te.Create(s, a)) + }) + + t.Run("create allows a running timer with no end", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: start} // EndTime nil + require.NoError(t, te.Create(s, a)) + }) + + t.Run("update rejects an end before the start", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Entry 1 is user1's completed entry. + te := &TimeEntry{ID: 1, TaskID: 1, StartTime: start, EndTime: timePtr(before)} + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + + err = te.Update(s, a) + require.Error(t, err) + assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err) + }) +} + +func TestTimeEntry_StopRunningTimer(t *testing.T) { + t.Run("stops the caller's running timer and returns it", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + entry, err := StopRunningTimer(s, &user.User{ID: 1}) // entry 4 + require.NoError(t, err) + require.NoError(t, s.Commit()) + + assert.Equal(t, int64(4), entry.ID) + assert.NotNil(t, entry.EndTime) + + reloaded := &TimeEntry{} + _, err = s.Where("id = ?", 4).Get(reloaded) + require.NoError(t, err) + assert.NotNil(t, reloaded.EndTime, "end time should be persisted") + }) + + t.Run("errors when no timer is running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := StopRunningTimer(s, &user.User{ID: 2}) // user2 has no entries + require.Error(t, err) + assert.True(t, IsErrNoRunningTimer(err), "unexpected error type: %v", err) + }) + + t.Run("denies a link share and leaves the matching user's timer running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Share id 1 collides with user 1, whose entry 4 is a running timer. + _, err := StopRunningTimer(s, &LinkSharing{ID: 1, ProjectID: 1}) + require.Error(t, err) + assert.True(t, IsErrGenericForbidden(err), "unexpected error type: %v", err) + + running := &TimeEntry{} + exists, err := s.Where("id = ?", 4).Get(running) + require.NoError(t, err) + require.True(t, exists) + assert.Nil(t, running.EndTime, "the user's timer must not have been stopped by a link share") + }) +} + +func TestTimeEntry_Events(t *testing.T) { + u := &user.User{ID: 1} + someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) + + t.Run("create dispatches created", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} + require.NoError(t, te.Create(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(context.Background(), s) + events.AssertDispatched(t, &TimeEntryCreatedEvent{}) + }) + + t.Run("update dispatches updated", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart, EndTime: someEnd, Comment: "edited"} + can, err := te.CanUpdate(s, u) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(context.Background(), s) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) + + t.Run("delete dispatches deleted", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(context.Background(), s) + events.AssertDispatched(t, &TimeEntryDeletedEvent{}) + }) + + t.Run("starting a timer dispatches created plus updated for the auto-stopped entry", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + // 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(context.Background(), s) + events.AssertDispatched(t, &TimeEntryCreatedEvent{}) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) + + t.Run("a completed manual entry dispatches only created", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} + require.NoError(t, te.Create(s, u)) + require.NoError(t, s.Commit()) + 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") + }) + + t.Run("StopRunningTimer dispatches updated", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + _, err := StopRunningTimer(s, u) + require.NoError(t, err) + require.NoError(t, s.Commit()) + events.DispatchPending(context.Background(), s) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) +} + +func TestTimeEntry_Delete(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + db.AssertMissing(t, "time_entries", map[string]interface{}{"id": 1}) +} + +func TestTimeEntry_TaskCount(t *testing.T) { + u := &user.User{ID: 1} + + t.Run("attaches counts for a licensed, non-share caller", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + task1 := &Task{ID: 1} // fixtures: time entries 1 and 4 are attached to task 1 + task2 := &Task{ID: 2} // no time entries + taskMap := map[int64]*Task{1: task1, 2: task2} + + require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1, 2}, taskMap)) + + require.NotNil(t, task1.TimeEntriesCount) + assert.Equal(t, int64(2), *task1.TimeEntriesCount) + require.NotNil(t, task2.TimeEntriesCount) + assert.Equal(t, int64(0), *task2.TimeEntriesCount) + }) + + t.Run("leaves the count unset for a link share", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + task1 := &Task{ID: 1} + taskMap := map[int64]*Task{1: task1} + require.NoError(t, addTimeEntriesCountToTasks(s, &LinkSharing{ID: 1}, []int64{1}, taskMap)) + assert.Nil(t, task1.TimeEntriesCount, "link shares must not learn time-entry counts") + }) + + t.Run("leaves the count unset when the feature is unlicensed", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.ResetForTests() // feature disabled + + task1 := &Task{ID: 1} + taskMap := map[int64]*Task{1: task1} + require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1}, taskMap)) + assert.Nil(t, task1.TimeEntriesCount, "an unlicensed instance must not expose counts") + }) +} diff --git a/pkg/models/user_project.go b/pkg/models/user_project.go index 34ea85abe..7fe98c772 100644 --- a/pkg/models/user_project.go +++ b/pkg/models/user_project.go @@ -18,10 +18,30 @@ package models import ( "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" "xorm.io/builder" "xorm.io/xorm" ) +// SearchUsersForProject performs the per-project user search shared by both API +// versions: it checks the caller can read the project, then lists the users +// with access to it. canRead is false (with no error) when the caller lacks +// read access, so each handler can map that to its own forbidden response. +func SearchUsersForProject(s *xorm.Session, project *Project, a web.Auth, currentUser *user.User, search string) (users []*user.User, canRead bool, err error) { + canRead, _, err = project.CanRead(s, a) + if err != nil { + return nil, false, err + } + if !canRead { + return nil, false, nil + } + users, err = ListUsersFromProject(s, project, currentUser, search) + if err != nil { + return nil, true, err + } + return users, true, nil +} + // ProjectUIDs hold all kinds of user IDs from accounts who have access to a project type ProjectUIDs struct { ProjectOwnerID int64 `xorm:"projectOwner"` diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go new file mode 100644 index 000000000..0d905cd1a --- /dev/null +++ b/pkg/models/user_settings.go @@ -0,0 +1,130 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "context" + + "code.vikunja.io/api/pkg/modules/avatar" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// UserGeneralSettings is the single user-settings wire struct shared by v1 and +// v2 — both the update request body and the nested settings on GET /user. A +// dedicated struct (not user.User) is required: user.User's settings fields are +// json:"-" so they don't leak when it is embedded in other responses +// (assignees, created_by, members …). +type UserGeneralSettings struct { + Name string `json:"name" doc:"The full name of the user."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."` + DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."` + DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."` + OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."` + OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."` + DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."` + WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."` + Language string `json:"language" doc:"The user's language."` + Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."` + FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."` + // Server/OpenID-provided; populated on read, ignored on write. + ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."` +} + +// NewUserGeneralSettings projects a user's stored settings into the shared wire +// struct for GET /user. Used by both the v1 and v2 user-show handlers. +func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { + return &UserGeneralSettings{ + Name: u.Name, + EmailRemindersEnabled: u.EmailRemindersEnabled, + DiscoverableByName: u.DiscoverableByName, + DiscoverableByEmail: u.DiscoverableByEmail, + OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, + OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, + DefaultProjectID: u.DefaultProjectID, + WeekStart: u.WeekStart, + Language: u.Language, + Timezone: u.Timezone, + FrontendSettings: u.FrontendSettings, + ExtraSettingsLinks: u.ExtraSettingsLinks, + } +} + +// ChangeUserPassword verifies the old password, sets the new one, and +// invalidates all of the user's sessions. Lives here (not in pkg/user) because +// it needs DeleteAllUserSessions, which pkg/user cannot import. +func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error { + if oldPassword == "" { + return user.ErrEmptyOldPassword{} + } + + if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + return err + } + + if err := user.UpdateUserPassword(s, u, newPassword); err != nil { + return err + } + + return DeleteAllUserSessions(s, u.ID) +} + +// UpdateUserGeneralSettings copies the general settings onto the user, persists +// them, and flushes the avatar cache when an initials avatar's name changed. +// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar, +// which pkg/user cannot import. +func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error { + invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name + + u.Name = settings.Name + u.EmailRemindersEnabled = settings.EmailRemindersEnabled + u.DiscoverableByEmail = settings.DiscoverableByEmail + u.DiscoverableByName = settings.DiscoverableByName + u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled + u.DefaultProjectID = settings.DefaultProjectID + u.WeekStart = settings.WeekStart + u.Language = settings.Language + u.Timezone = settings.Timezone + u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime + u.FrontendSettings = settings.FrontendSettings + + if _, err := user.UpdateUser(s, u, true); err != nil { + return err + } + + if invalidateAvatar { + avatar.FlushAllCaches(u) + } + return nil +} + +// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and +// flushes the avatar cache when the provider changes (or is set to initials). +func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error { + oldProvider := u.AvatarProvider + u.AvatarProvider = provider + + if _, err := user.UpdateUser(s, u, false); err != nil { + return err + } + + if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider { + avatar.FlushAllCaches(u) + } + return nil +} diff --git a/pkg/models/users.go b/pkg/models/users.go index da2b7af97..b5a79f510 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -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 { diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index fd7ee8e81..b4038bf6c 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -40,6 +40,7 @@ import ( "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web" + "xorm.io/builder" "xorm.io/xorm" ) @@ -216,24 +217,36 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 500 {object} models.Message "Internal server error" // @Router /projects/{id}/webhooks [get] func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { - p := &Project{ID: w.ProjectID} - can, _, err := p.CanRead(s, a) - if err != nil { - return nil, 0, 0, err - } - if !can { - return nil, 0, 0, ErrGenericForbidden{} + // w.UserID set selects the user-level list: a user may only see their own + // webhooks. The project list (w.UserID == 0) delegates to the project's read + // permission instead. + var listCond builder.Cond + if w.UserID > 0 { + if _, isShareAuth := a.(*LinkSharing); isShareAuth || w.UserID != a.GetID() { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"user_id": w.UserID} + } else { + p := &Project{ID: w.ProjectID} + can, _, cerr := p.CanRead(s, a) + if cerr != nil { + return nil, 0, 0, cerr + } + if !can { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"project_id": w.ProjectID} } ws := []*Webhook{} - err = s.Where("project_id = ?", w.ProjectID). + err = s.Where(listCond). Limit(getLimitFromPageIndex(page, perPage)). Find(&ws) if err != nil { return } - total, err := s.Where("project_id = ?", w.ProjectID). + total, err := s.Where(listCond). Count(&Webhook{}) if err != nil { return diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 61a5f9b13..87c89e5aa 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -26,6 +26,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/humaecho5" "code.vikunja.io/api/pkg/user" @@ -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. diff --git a/pkg/modules/auth/oauth2server/authorize.go b/pkg/modules/auth/oauth2server/authorize.go index 873c00900..96afbbad7 100644 --- a/pkg/modules/auth/oauth2server/authorize.go +++ b/pkg/modules/auth/oauth2server/authorize.go @@ -26,8 +26,8 @@ import ( "github.com/labstack/echo/v5" ) -// authorizeRequest represents the JSON body for the authorize endpoint. -type authorizeRequest struct { +// AuthorizeRequest represents the body for the authorize endpoint. +type AuthorizeRequest struct { ResponseType string `json:"response_type"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` @@ -47,54 +47,66 @@ type AuthorizeResponse struct { // It validates the OAuth parameters, creates an authorization code, and // returns it as JSON. Authentication is handled by the token middleware. func HandleAuthorize(c *echo.Context) error { - var req authorizeRequest + var req AuthorizeRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - // Validate response_type - if req.ResponseType != "code" { - return echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") - } - - // Validate redirect_uri - if !ValidateRedirectURI(req.RedirectURI) { - return &models.ErrOAuthInvalidRedirectURI{} - } - - // Validate PKCE (required) - if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { - return &models.ErrOAuthMissingPKCE{} - } - // Get the authenticated user from the middleware u, err := user.GetCurrentUser(c) if err != nil { return err } + resp, err := Authorize(&req, u.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, resp) +} + +// Authorize validates the OAuth authorization parameters for the given +// authenticated user and creates a single-use authorization code, independent +// of the HTTP layer. Callers own request binding and resolving the user. +func Authorize(req *AuthorizeRequest, userID int64) (*AuthorizeResponse, error) { + // Validate response_type + if req.ResponseType != "code" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") + } + + // Validate redirect_uri + if !ValidateRedirectURI(req.RedirectURI) { + return nil, &models.ErrOAuthInvalidRedirectURI{} + } + + // Validate PKCE (required) + if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { + return nil, &models.ErrOAuthMissingPKCE{} + } + s := db.NewSession() defer s.Close() - fullUser, err := user.GetUserByID(s, u.ID) + fullUser, err := user.GetUserByID(s, userID) if err != nil { _ = s.Rollback() - return err + return nil, err } code, err := models.CreateOAuthCode(s, fullUser.ID, req.ClientID, req.RedirectURI, req.CodeChallenge, req.CodeChallengeMethod) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - return c.JSON(http.StatusOK, AuthorizeResponse{ + return &AuthorizeResponse{ Code: code, RedirectURI: req.RedirectURI, State: req.State, - }) + }, nil } diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 2725b988d..97978c0bf 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -17,10 +17,14 @@ package oauth2server import ( + "context" + "net/http" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/user" @@ -36,35 +40,51 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token"` } -// tokenRequest holds the JSON body of a POST /oauth/token request. -type tokenRequest struct { - GrantType string `json:"grant_type"` - Code string `json:"code"` - ClientID string `json:"client_id"` - RedirectURI string `json:"redirect_uri"` - CodeVerifier string `json:"code_verifier"` - RefreshToken string `json:"refresh_token"` +// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it +// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well +// (form tags mirror the json names). +type TokenRequest struct { + GrantType string `json:"grant_type" form:"grant_type"` + Code string `json:"code" form:"code"` + ClientID string `json:"client_id" form:"client_id"` + RedirectURI string `json:"redirect_uri" form:"redirect_uri"` + CodeVerifier string `json:"code_verifier" form:"code_verifier"` + RefreshToken string `json:"refresh_token" form:"refresh_token"` } // HandleToken handles POST /oauth/token. // Supports grant_type=authorization_code and grant_type=refresh_token. func HandleToken(c *echo.Context) error { - var req tokenRequest + var req TokenRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } + resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP()) + if err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, resp) +} + +// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth +// token endpoint, independent of the HTTP layer. Callers own request binding and +// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded +// on the session created for the authorization_code grant. +func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return handleAuthorizationCodeGrant(c, &req) + return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress) case "refresh_token": - return handleRefreshTokenGrant(c, &req) + return exchangeRefreshToken(req) default: - return &models.ErrOAuthInvalidGrantType{} + return nil, &models.ErrOAuthInvalidGrantType{} } } -func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { +func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -72,73 +92,75 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code) if err != nil { _ = s.Rollback() - return err + return nil, err } // Validate client_id matches if oauthCode.ClientID != req.ClientID { _ = s.Rollback() - return &models.ErrOAuthClientNotFound{} + return nil, &models.ErrOAuthClientNotFound{} } // Validate redirect_uri matches if oauthCode.RedirectURI != req.RedirectURI { _ = s.Rollback() - return &models.ErrOAuthInvalidRedirectURI{} + return nil, &models.ErrOAuthInvalidRedirectURI{} } // Verify PKCE if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) { _ = s.Rollback() - return &models.ErrOAuthPKCEVerifyFailed{} + return nil, &models.ErrOAuthPKCEVerifyFailed{} } // Create a session (reuses existing session infrastructure) - deviceInfo := c.Request().UserAgent() - ipAddress := c.RealIP() - session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) + 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 } diff --git a/pkg/modules/auth/openid/logout.go b/pkg/modules/auth/openid/logout.go new file mode 100644 index 000000000..958ea8765 --- /dev/null +++ b/pkg/modules/auth/openid/logout.go @@ -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 . + +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 +} diff --git a/pkg/modules/auth/openid/logout_test.go b/pkg/modules/auth/openid/logout_test.go new file mode 100644 index 000000000..57e3ea6a2 --- /dev/null +++ b/pkg/modules/auth/openid/logout_test.go @@ -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 . + +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()) +} diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 381570f42..3fd7a6cfa 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -27,6 +27,7 @@ import ( "strings" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -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 } diff --git a/pkg/modules/auth/openid/openid_test.go b/pkg/modules/auth/openid/openid_test.go index 05ade6745..35bb27547 100644 --- a/pkg/modules/auth/openid/openid_test.go +++ b/pkg/modules/auth/openid/openid_test.go @@ -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) { diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go index 30f534ad9..710870b7d 100644 --- a/pkg/modules/auth/openid/providers.go +++ b/pkg/modules/auth/openid/providers.go @@ -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 } diff --git a/pkg/modules/background/background.go b/pkg/modules/background/background.go index 161485bfe..7e48d2d16 100644 --- a/pkg/modules/background/background.go +++ b/pkg/modules/background/background.go @@ -24,12 +24,12 @@ import ( // Image represents an image which can be used as a project background type Image struct { - ID string `json:"id"` - URL string `json:"url"` - Thumb string `json:"thumb,omitempty"` - BlurHash string `json:"blur_hash"` + ID string `json:"id" doc:"The provider-specific id of the image; pass this back to set it as a background."` + URL string `json:"url" doc:"The full-size URL of the image."` + Thumb string `json:"thumb,omitempty" doc:"A thumbnail URL of the image, if the provider supplies one."` + BlurHash string `json:"blur_hash" doc:"A BlurHash placeholder for the image."` // This can be used to supply extra information from an image provider to clients - Info interface{} `json:"info,omitempty"` + Info interface{} `json:"info,omitempty" doc:"Provider-specific extra information about the image (e.g. the Unsplash author for attribution)."` } const MaxBackgroundImageHeight = 3840 diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index a89784ee9..da9d0e522 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -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 diff --git a/pkg/modules/background/handler/errors.go b/pkg/modules/background/handler/errors.go index beaf46657..dcddf1687 100644 --- a/pkg/modules/background/handler/errors.go +++ b/pkg/modules/background/handler/errors.go @@ -40,3 +40,22 @@ func IsErrFileUnsupportedImageFormat(err error) bool { ok := errors.As(err, &errFileUnsupportedImageFormat) return ok } + +// ErrFileIsNoImage is returned when an uploaded background does not sniff as an +// image at all (its detected mime type does not start with "image"). It is +// distinct from ErrFileUnsupportedImageFormat, which is a recognized image type +// the imaging library can't decode. +type ErrFileIsNoImage struct { + Mime string +} + +// Error is the error implementation of ErrFileIsNoImage +func (err ErrFileIsNoImage) Error() string { + return fmt.Sprintf("uploaded file is not an image [Mime: %s]", err.Mime) +} + +// IsErrFileIsNoImage checks if an error is ErrFileIsNoImage +func IsErrFileIsNoImage(err error) bool { + var errFileIsNoImage ErrFileIsNoImage + return errors.As(err, &errFileIsNoImage) +} diff --git a/pkg/modules/background/unsplash/proxy.go b/pkg/modules/background/unsplash/proxy.go index 69fb23716..1d0b11607 100644 --- a/pkg/modules/background/unsplash/proxy.go +++ b/pkg/modules/background/unsplash/proxy.go @@ -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) } diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 0e9c9b942..d59dd6946 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -18,6 +18,7 @@ package migration import ( "bytes" + "context" "xorm.io/xorm" @@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) return nil } diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go index a0bee4f08..6d9ded38d 100644 --- a/pkg/modules/migration/csv/csv.go +++ b/pkg/modules/migration/csv/csv.go @@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{ // ColumnMapping represents a mapping from a CSV column to a task attribute type ColumnMapping struct { - ColumnIndex int `json:"column_index"` - ColumnName string `json:"column_name"` - Attribute TaskAttribute `json:"attribute"` + ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."` + ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."` + Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."` } // DetectionResult contains the auto-detected CSV structure type DetectionResult struct { - Columns []string `json:"columns"` - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SuggestedMapping []ColumnMapping `json:"suggested_mapping"` - PreviewRows [][]string `json:"preview_rows"` + Columns []string `json:"columns" doc:"The detected column header names, in order."` + Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."` + QuoteChar string `json:"quote_char" doc:"The detected quote character."` + DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."` + SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."` + PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."` } // ImportConfig contains the configuration for CSV import type ImportConfig struct { - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SkipRows int `json:"skip_rows"` - Mapping []ColumnMapping `json:"mapping"` + Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."` + QuoteChar string `json:"quote_char" doc:"The quote character to parse with."` + DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."` + SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."` + Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."` } // PreviewTask represents a task preview before import @@ -146,8 +146,8 @@ type PreviewTask struct { // PreviewResult contains preview data before import type PreviewResult struct { - Tasks []PreviewTask `json:"tasks"` - TotalRows int `json:"total_rows"` + Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."` + TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."` } // stripBOM removes the UTF-8 BOM from the beginning of a reader @@ -557,6 +557,22 @@ func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error { return &migration.ErrCSVConfigRequired{} } +// RunMigration records the migration's start, imports the CSV with the given +// config and records its finish. Shared by the v1 and v2 HTTP layers so the +// status bookkeeping around MigrateWithConfig lives in one place. +func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { + status, err := migration.StartMigration(&Migrator{}, u) + if err != nil { + return err + } + + if err := MigrateWithConfig(u, file, size, config); err != nil { + return err + } + + return migration.FinishMigration(status) +} + // MigrateWithConfig imports CSV data into Vikunja with the provided configuration func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { if size == 0 { diff --git a/pkg/modules/migration/csv/handler.go b/pkg/modules/migration/csv/handler.go index 389c13573..1a99c342d 100644 --- a/pkg/modules/migration/csv/handler.go +++ b/pkg/modules/migration/csv/handler.go @@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error { } defer src.Close() - m := &Migrator{} - status, err := migration.StartMigration(m, u) - if err != nil { - return err - } - - err = MigrateWithConfig(u, src, file.Size, &config) - if err != nil { - return err - } - - err = migration.FinishMigration(status) - if err != nil { + if err := RunMigration(u, src, file.Size, &config); err != nil { return err } diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go index 3129c5da2..eef789c39 100644 --- a/pkg/modules/migration/errors.go +++ b/pkg/modules/migration/errors.go @@ -18,10 +18,33 @@ package migration import ( "net/http" + "time" "code.vikunja.io/api/pkg/web" ) +// ErrMigrationAlreadyRunning is returned when a migration is started for a user +// who already has one in progress (started but not yet finished). +type ErrMigrationAlreadyRunning struct { + StartedAt time.Time +} + +func (err *ErrMigrationAlreadyRunning) Error() string { + return "Migration already running" +} + +// ErrCodeMigrationAlreadyRunning holds the unique world-error code of this error +const ErrCodeMigrationAlreadyRunning = 14005 + +// HTTPError holds the http error description +func (err *ErrMigrationAlreadyRunning) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeMigrationAlreadyRunning, + Message: "Migration already running", + } +} + // ErrNotAZipFile represents a "ErrNotAZipFile" kind of error. type ErrNotAZipFile struct{} diff --git a/pkg/modules/migration/handler/handler.go b/pkg/modules/migration/handler/handler.go index 840dbacd6..5ef52d747 100644 --- a/pkg/modules/migration/handler/handler.go +++ b/pkg/modules/migration/handler/handler.go @@ -39,7 +39,7 @@ type MigrationWeb struct { // AuthURL is returned to the user when requesting the auth url type AuthURL struct { - URL string `json:"url"` + URL string `json:"url" readOnly:"true" doc:"The OAuth authorization url the client should redirect the user to. After authorizing, the obtained code is passed back to the migrate endpoint."` } // RegisterMigrator registers all routes for migration @@ -57,6 +57,28 @@ func (mw *MigrationWeb) AuthURL(c *echo.Context) error { return c.JSON(http.StatusOK, &AuthURL{URL: ms.AuthURL()}) } +// StartMigration kicks off a migration for the given user: it refuses with +// migration.ErrMigrationAlreadyRunning if one is already in progress, then +// dispatches the MigrationRequestedEvent that runs the migration asynchronously. +// The migrator must already carry its request payload (e.g. the OAuth code). +// Shared by the v1 and v2 HTTP layers so the orchestration lives in one place. +func StartMigration(ms migration.Migrator, u *user2.User) error { + stats, err := migration.GetMigrationStatus(ms, u) + if err != nil { + return err + } + + if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() { + return &migration.ErrMigrationAlreadyRunning{StartedAt: stats.StartedAt} + } + + return events.Dispatch(&MigrationRequestedEvent{ + Migrator: ms, + MigratorKind: ms.Name(), + User: u, + }) +} + // Migrate calls the migration method func (mw *MigrationWeb) Migrate(c *echo.Context) error { ms := mw.MigrationStruct() @@ -85,12 +107,7 @@ func (mw *MigrationWeb) Migrate(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error()).Wrap(err) } - err = events.Dispatch(&MigrationRequestedEvent{ - Migrator: ms, - MigratorKind: ms.Name(), - User: user, - }) - if err != nil { + if err := StartMigration(ms, user); err != nil { return err } diff --git a/pkg/modules/migration/handler/handler_file.go b/pkg/modules/migration/handler/handler_file.go index 8fae1d775..76b7f4d13 100644 --- a/pkg/modules/migration/handler/handler_file.go +++ b/pkg/modules/migration/handler/handler_file.go @@ -17,6 +17,7 @@ package handler import ( + "io" "net/http" "code.vikunja.io/api/pkg/models" @@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) { g.PUT("/"+ms.Name()+"/migrate", fw.Migrate) } +// RunFileMigration records the migration's start, runs the file migrator and +// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration +// lives in one place; the caller supplies the already-opened upload. +func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error { + m, err := migration.StartMigration(ms, u) + if err != nil { + return err + } + + if err := ms.Migrate(u, file, size); err != nil { + return err + } + + return migration.FinishMigration(m) +} + // Migrate calls the migration method func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { ms := fw.MigrationStruct() @@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { } defer src.Close() - m, err := migration.StartMigration(ms, user) - if err != nil { - return err - } - - // Do the migration - err = ms.Migrate(user, src, file.Size) - if err != nil { - return err - } - - err = migration.FinishMigration(m) - if err != nil { + if err := RunFileMigration(ms, user, src, file.Size); err != nil { return err } diff --git a/pkg/modules/migration/migration_status.go b/pkg/modules/migration/migration_status.go index a967c950c..419ac880c 100644 --- a/pkg/modules/migration/migration_status.go +++ b/pkg/modules/migration/migration_status.go @@ -25,11 +25,11 @@ import ( // Status represents this migration status type Status struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this migration status."` UserID int64 `xorm:"bigint not null" json:"-"` - MigratorName string `xorm:"varchar(255)" json:"migrator_name"` - StartedAt time.Time `xorm:"not null" json:"started_at"` - FinishedAt time.Time `xorm:"null" json:"finished_at"` + MigratorName string `xorm:"varchar(255)" json:"migrator_name" readOnly:"true" doc:"The name of the migrator this status belongs to, e.g. \"todoist\"."` + StartedAt time.Time `xorm:"not null" json:"started_at" readOnly:"true" doc:"When the last migration started. Zero value if the user never migrated from this service."` + FinishedAt time.Time `xorm:"null" json:"finished_at" readOnly:"true" doc:"When the last migration finished. Zero value while a migration is still running or was never run."` } // TableName holds the table name for the migration status table diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 292927c80..7749e1d9c 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -20,6 +20,7 @@ import ( "bytes" "embed" templatehtml "html/template" + "net/url" "regexp" "strings" templatetext "text/template" @@ -187,16 +188,26 @@ const mailTemplateConversationalHTML = ` //go:embed logo.png var logo embed.FS -func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { +// newNotificationSanitizer builds the bluemonday policy for all HTML in notification +// emails. Only inline data-URI images (avatars) are allowed: RewriteSrc blanks any +// remote image src so a user-controlled task title, comment or description can't +// smuggle a tracking pixel into a recipient's inbox. +func newNotificationSanitizer() *bluemonday.Policy { p := bluemonday.UGCPolicy() - // Allow data URI images for inline avatars in mentions p.AllowDataURIImages() - // Allow style attribute on img and div elements for avatar and layout styling p.AllowAttrs("style").OnElements("img", "div") - // Allow specific CSS properties for avatar styling p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") - // Allow padding styles on div elements for content spacing p.AllowStyles("padding-top", "margin-bottom").OnElements("div") + p.RewriteSrc(func(u *url.URL) { + if u.Scheme != "data" { + *u = url.URL{} + } + }) + return p +} + +func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { + p := newNotificationSanitizer() for _, line := range lines { if line.isHTML { @@ -225,11 +236,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e // sanitizeLinesToHTML sanitizes lines without wrapping in

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

hi

`). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc) + assert.Contains(t, mailOpts.HTMLMessage, "hi") + }) + + t.Run("inline data-URI avatar is preserved", func(t *testing.T) { + const avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + header := CreateConversationalHeader(avatar, "alice left a comment", "https://example.com/task/1", "Project", "#1", "Task") + + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HeaderLine(header). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.Contains(t, mailOpts.HTMLMessage, "data:image/png;base64,") + }) +} diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go new file mode 100644 index 000000000..4459f2698 --- /dev/null +++ b/pkg/routes/api/shared/admin_user.go @@ -0,0 +1,64 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// AdminUser re-exposes fields hidden by the default user.User JSON view. +type AdminUser struct { + *user.User + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"Whether the user is an instance admin."` + Status user.Status `json:"status" readOnly:"true" doc:"Account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked)."` + Issuer string `json:"issuer" readOnly:"true" doc:"Authentication issuer; empty or 'local' for local accounts."` + Subject string `json:"subject,omitempty" readOnly:"true" doc:"External subject identifier, for non-local accounts."` + AuthProvider string `json:"auth_provider,omitempty" readOnly:"true" doc:"Resolved auth provider name (e.g. 'LDAP' or an OIDC provider), empty for local accounts."` +} + +// NewAdminUser builds the admin-facing user view, resolving the auth-provider +// display name from the configured OIDC providers. +func NewAdminUser(u *user.User, providers []*openid.Provider) *AdminUser { + return &AdminUser{ + User: u, + IsAdmin: u.IsAdmin, + Status: u.Status, + Issuer: u.Issuer, + Subject: u.Subject, + AuthProvider: resolveAuthProvider(u, providers), + } +} + +func resolveAuthProvider(u *user.User, providers []*openid.Provider) string { + switch u.Issuer { + case "", user.IssuerLocal: + return "" + case user.IssuerLDAP: + return "LDAP" + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + continue + } + if issuerURL == u.Issuer { + return provider.Name + } + } + return u.Issuer +} diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go new file mode 100644 index 000000000..560ae0f47 --- /dev/null +++ b/pkg/routes/api/shared/auth.go @@ -0,0 +1,343 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "context" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/ldap" + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// UserRegister carries the fields accepted by the public registration endpoint: +// username, password and email (from APIUserPassword) plus the new user's +// preferred language. +type UserRegister struct { + // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. + Language string `json:"language" valid:"language" doc:"The language of the new user as an IETF BCP 47 code (e.g. en, de-DE)."` + user.APIUserPassword +} + +// RegisterUser creates a new local user account from the registration input and +// busts the cached user-count metric so the registration shows up immediately. +// The caller is responsible for the registration-enabled gate and input +// validation; both v1 and v2 share this body. +func RegisterUser(ctx context.Context, in *UserRegister) (*user.User, error) { + s := db.NewSession() + defer s.Close() + // Discards events queued during a rolled-back transaction; a no-op once + // DispatchPending has run. + defer events.CleanupPending(s) + + newUser, err := models.RegisterUser(s, &user.User{ + Username: in.Username, + Password: in.Password, + Email: in.Email, + Language: in.Language, + }) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchPending(ctx, s) + + // Bust the cached user count so the new registration shows up in metrics + // immediately instead of after the regular cache expiry. + if config.MetricsEnabled.GetBool() { + if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { + log.Errorf("Could not invalidate user count metric: %s", err) + } + } + + return newUser, nil +} + +// AuthenticateUserCredentials verifies a login against local (and, if configured, +// LDAP) credentials and enforces the account-status and TOTP gates, returning the +// authenticated user on success. It is the transport-agnostic core of the login +// flow shared by v1 and v2; the caller issues the token and sets the cookie. The +// returned errors carry their own HTTP semantics (wrong credentials, disabled +// account, missing/invalid TOTP) so both APIs surface them identically. +func AuthenticateUserCredentials(ctx context.Context, login *user.Login) (*user.User, error) { + s := db.NewSession() + defer s.Close() + // Discards events queued during a rolled-back transaction (e.g. LDAP user + // creation); a no-op once DispatchPending has run. + defer events.CleanupPending(s) + + u, err := resolveLoginUser(ctx, s, login) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.Status == user.StatusDisabled { + _ = s.Rollback() + return nil, &user.ErrAccountDisabled{UserID: u.ID} + } + if u.Status == user.StatusAccountLocked { + _ = s.Rollback() + return nil, &user.ErrAccountLocked{UserID: u.ID} + } + + if err := enforceLoginTOTP(s, u, login.TOTPPasscode); err != nil { + return nil, err + } + + if err := keyvalue.Del(u.GetFailedTOTPAttemptsKey()); err != nil { + return nil, err + } + if err := keyvalue.Del(u.GetFailedPasswordAttemptsKey()); err != nil { + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchPending(ctx, s) + + return u, nil +} + +// resolveLoginUser authenticates the credentials against LDAP (when enabled) and +// then against local accounts, mirroring v1's order so local users keep working +// alongside LDAP. Bots are rejected before bcrypt runs because they have no +// password hash. +func resolveLoginUser(ctx context.Context, s *xorm.Session, login *user.Login) (*user.User, error) { + if config.AuthLdapEnabled.GetBool() { + u, err := ldap.AuthenticateUserInLDAP(s, login.Username, login.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString()) + if err != nil && !user.IsErrWrongUsernameOrPassword(err) { + return nil, err + } + if u != nil { + return u, nil + } + } + + existingUser, lookupErr := user.GetUserByUsername(s, login.Username) + if lookupErr == nil && existingUser.IsBot() { + return nil, &user.ErrAccountIsBot{UserID: existingUser.ID} + } + + return user.CheckUserCredentials(ctx, s, login) +} + +// enforceLoginTOTP runs the TOTP gate for users who have it enabled, mirroring +// v1: a missing passcode is rejected, and a wrong one trips the failed-attempt +// lockout via HandleFailedTOTPAuth. The session is rolled back before +// HandleFailedTOTPAuth so its dedicated session can acquire a write lock on +// SQLite shared-cache (the lockout write is decoupled from this transaction — +// see GHSA-fgfv-pv97-6cmj). +func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error { + totpEnabled, err := user.TOTPEnabledForUser(s, u) + if err != nil { + _ = s.Rollback() + return err + } + if !totpEnabled { + return nil + } + + if passcode == "" { + _ = s.Rollback() + return user.ErrInvalidTOTPPasscode{} + } + + _, err = user.ValidateTOTPPasscode(s, &user.TOTPPasscode{User: u, Passcode: passcode}) + if err != nil { + _ = s.Rollback() + if user.IsErrInvalidTOTPPasscode(err) { + user.HandleFailedTOTPAuth(u) + } + return err + } + + return nil +} + +// DeleteSession removes the session with the given id, logging the user out +// server-side. An empty sid is a no-op (the token carried no session, e.g. an +// API token or a link share), matching v1. Shared by v1 and v2; the caller is +// responsible for clearing the refresh cookie. +func DeleteSession(sid string) error { + _, err := LogoutSession(sid) + return err +} + +// LogoutSession deletes the session and returns its OIDC RP-Initiated Logout URL +// for the frontend to redirect to (empty for non-OIDC sessions or when no logout +// endpoint is configured). An empty sid is a no-op. The caller clears the refresh +// cookie. +func LogoutSession(sid string) (endSessionURL string, err error) { + if sid == "" { + return "", nil + } + + s := db.NewSession() + defer s.Close() + + // Read before deleting so the stored id_token survives for the logout URL. + // A missing session just means there is nothing to log out. + session, err := models.GetSessionByID(s, sid) + if err != nil && !models.IsErrSessionNotFound(err) { + _ = s.Rollback() + return "", err + } + if session != nil && session.OIDCProviderKey != "" { + url, buildErr := openid.BuildEndSessionURL(session.OIDCProviderKey, &models.SessionOIDCData{ + IDToken: session.OIDCIDToken, + ProviderKey: session.OIDCProviderKey, + }) + if buildErr != nil { + // A failed URL build must not block logout; the session is still deleted below. + log.Errorf("Could not build OIDC end-session URL for session %s: %v", sid, buildErr) + } else { + endSessionURL = url + } + } + + if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil { + _ = s.Rollback() + return "", err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return "", err + } + + return endSessionURL, nil +} + +// ResetPassword resets a user's password from a previously issued reset token +// and invalidates all of that user's sessions, so a leaked password cannot be +// used after a reset. Shared by v1 and v2. +func ResetPassword(reset *user.PasswordReset) error { + s := db.NewSession() + defer s.Close() + + userID, err := user.ResetPassword(s, reset) + if err != nil { + _ = s.Rollback() + return err + } + + if err := models.DeleteAllUserSessions(s, userID); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// RequestPasswordResetToken issues a password-reset token for the account with +// the given email and sends it via email. Shared by v1 and v2. +func RequestPasswordResetToken(req *user.PasswordTokenRequest) error { + s := db.NewSession() + defer s.Close() + + if err := user.RequestUserPasswordResetTokenByEmail(s, req); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// ConfirmEmail confirms a newly registered user's email from the token sent to +// them. Shared by v1 and v2. +func ConfirmEmail(confirm *user.EmailConfirm) error { + s := db.NewSession() + defer s.Close() + + if err := user.ConfirmEmail(s, confirm); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// LinkShareToken is the response for the link-share auth endpoint. It embeds the +// authenticated share alongside the issued JWT and re-exposes the project id +// (which LinkSharing hides with json:"-"). The embedded share's write-only +// Password is blanked by AuthenticateLinkShare before this is returned. +type LinkShareToken struct { + auth.Token + *models.LinkSharing + ProjectID int64 `json:"project_id" readOnly:"true" doc:"The id of the project this share grants access to."` +} + +// AuthenticateLinkShare resolves a link share by its public hash, verifies the +// password for password-protected shares, and issues a JWT auth token for it. +// The returned token's embedded share has its password blanked. Shared by v1 +// and v2. +func AuthenticateLinkShare(hash, password string) (*LinkShareToken, error) { + s := db.NewSession() + defer s.Close() + + share, err := models.GetLinkShareByHash(s, hash) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if share.SharingType == models.SharingTypeWithPassword { + if err := models.VerifyLinkSharePassword(share, password); err != nil { + _ = s.Rollback() + return nil, err + } + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + share.Password = "" + + return &LinkShareToken{ + Token: auth.Token{Token: t}, + LinkSharing: share, + ProjectID: share.ProjectID, + }, nil +} diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go new file mode 100644 index 000000000..042a5567d --- /dev/null +++ b/pkg/routes/api/shared/auth_provider.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package shared holds helpers used by both the v1 and v2 route packages. It +// sits above the auth/user modules in the import graph, so it can combine them +// without creating a cycle. +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// GetAuthProviderName resolves the human-readable name of the source a user +// authenticated with: "local"/"ldap" for those issuers, otherwise the +// configured OpenID provider whose issuer URL matches the user's. Returns "" +// when no provider matches. +func GetAuthProviderName(u *user.User) (string, error) { + switch u.Issuer { + case user.IssuerLocal: + return "local", nil + case user.IssuerLDAP: + return "ldap", nil + } + + providers, err := openid.GetAllProviders() + if err != nil { + return "", err + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + return "", err + } + if issuerURL == u.Issuer { + return provider.Name, nil + } + } + + return "", nil +} diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go new file mode 100644 index 000000000..48cbb00a5 --- /dev/null +++ b/pkg/routes/api/shared/info.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/auth/openid" + csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" + microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" + "code.vikunja.io/api/pkg/modules/migration/ticktick" + "code.vikunja.io/api/pkg/modules/migration/todoist" + "code.vikunja.io/api/pkg/modules/migration/trello" + vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" + "code.vikunja.io/api/pkg/modules/migration/wekan" + "code.vikunja.io/api/pkg/version" +) + +// VikunjaInfos holds public information about this Vikunja instance. +type VikunjaInfos struct { + Version string `json:"version" doc:"The Vikunja version this instance runs."` + FrontendURL string `json:"frontend_url" doc:"The publicly configured frontend URL of this instance."` + Motd string `json:"motd" doc:"The message of the day, shown to all users."` + LinkSharingEnabled bool `json:"link_sharing_enabled" doc:"Whether sharing projects via public links is enabled."` + MaxFileSize string `json:"max_file_size" doc:"The maximum allowed upload size, as a human-readable string (e.g. 20MB)."` + MaxItemsPerPage int `json:"max_items_per_page" doc:"The maximum number of items a paginated endpoint returns per page."` + AvailableMigrators []string `json:"available_migrators" doc:"The migrators enabled on this instance."` + TaskAttachmentsEnabled bool `json:"task_attachments_enabled" doc:"Whether task attachments are enabled."` + EnabledBackgroundProviders []string `json:"enabled_background_providers" doc:"The project-background providers enabled on this instance (e.g. upload, unsplash)."` + TotpEnabled bool `json:"totp_enabled" doc:"Whether TOTP two-factor authentication is enabled."` + Legal LegalInfo `json:"legal" doc:"Links to the instance's legal documents."` + CaldavEnabled bool `json:"caldav_enabled" doc:"Whether the CalDAV interface is enabled."` + AuthInfo AuthInfo `json:"auth" doc:"The authentication methods enabled on this instance."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"Whether email reminders are enabled."` + UserDeletionEnabled bool `json:"user_deletion_enabled" doc:"Whether users may delete their own account."` + TaskCommentsEnabled bool `json:"task_comments_enabled" doc:"Whether task comments are enabled."` + DemoModeEnabled bool `json:"demo_mode_enabled" doc:"Whether this instance runs in demo mode (data is periodically reset)."` + WebhooksEnabled bool `json:"webhooks_enabled" doc:"Whether webhooks are enabled."` + PublicTeamsEnabled bool `json:"public_teams_enabled" doc:"Whether public teams are enabled."` + AllowIconChanges bool `json:"allow_icon_changes" doc:"Whether users may change project icons."` + EnabledProFeatures []license.Feature `json:"enabled_pro_features" doc:"The licensed pro features enabled on this instance."` + // ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel. + ConcurrentWrites bool `json:"concurrent_writes" doc:"Whether the configured database supports concurrent writes. False on SQLite; clients should serialize batched writes when this is false."` +} + +// AuthInfo describes the authentication methods enabled on this instance. +type AuthInfo struct { + Local LocalAuthInfo `json:"local"` + Ldap LdapAuthInfo `json:"ldap"` + OpenIDConnect OpenIDAuthInfo `json:"openid_connect"` +} + +// LocalAuthInfo describes the local (username/password) authentication method. +type LocalAuthInfo struct { + Enabled bool `json:"enabled"` + RegistrationEnabled bool `json:"registration_enabled"` +} + +// LdapAuthInfo describes the LDAP authentication method. +type LdapAuthInfo struct { + Enabled bool `json:"enabled"` +} + +// OpenIDAuthInfo describes the OpenID Connect authentication method. +type OpenIDAuthInfo struct { + Enabled bool `json:"enabled"` + Providers []*openid.Provider `json:"providers"` +} + +// LegalInfo holds links to the instance's legal documents. +type LegalInfo struct { + ImprintURL string `json:"imprint_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` +} + +// BuildInfo assembles the public instance information returned by GET /info on +// both API versions. +func BuildInfo() VikunjaInfos { + info := VikunjaInfos{ + Version: version.Version, + FrontendURL: config.ServicePublicURL.GetString(), + Motd: config.ServiceMotd.GetString(), + LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(), + MaxFileSize: config.FilesMaxSize.GetString(), + MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(), + TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), + TotpEnabled: config.ServiceEnableTotp.GetBool(), + CaldavEnabled: config.ServiceEnableCaldav.GetBool(), + EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), + UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(), + TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(), + DemoModeEnabled: config.ServiceDemoMode.GetBool(), + WebhooksEnabled: config.WebhooksEnabled.GetBool(), + PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), + AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), + ConcurrentWrites: config.DatabaseType.GetString() != "sqlite", + EnabledProFeatures: license.EnabledProFeatures(), + AvailableMigrators: []string{ + (&vikunja_file.FileMigrator{}).Name(), + (&ticktick.Migrator{}).Name(), + (&wekan.Migrator{}).Name(), + (&csvmigrator.Migrator{}).Name(), + }, + Legal: LegalInfo{ + ImprintURL: config.LegalImprintURL.GetString(), + PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), + }, + AuthInfo: AuthInfo{ + Local: LocalAuthInfo{ + Enabled: config.AuthLocalEnabled.GetBool(), + RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(), + }, + Ldap: LdapAuthInfo{ + Enabled: config.AuthLdapEnabled.GetBool(), + }, + OpenIDConnect: OpenIDAuthInfo{ + Enabled: config.AuthOpenIDEnabled.GetBool(), + }, + }, + } + + providers, err := openid.GetAllProviders() + if err != nil { + log.Errorf("Error while getting openid providers for /info: %s", err) + // No return here to not break /info + } + info.AuthInfo.OpenIDConnect.Providers = providers + + if config.MigrationTodoistEnable.GetBool() { + m := &todoist.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + if config.MigrationTrelloEnable.GetBool() { + m := &trello.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + if config.MigrationMicrosoftTodoEnable.GetBool() { + m := µsofttodo.Migration{} + info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) + } + + if config.BackgroundsEnabled.GetBool() { + if config.BackgroundsUploadEnabled.GetBool() { + info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload") + } + if config.BackgroundsUnsplashEnabled.GetBool() { + info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash") + } + } + + return info +} diff --git a/pkg/routes/api/shared/testing.go b/pkg/routes/api/shared/testing.go new file mode 100644 index 000000000..ba9118e5a --- /dev/null +++ b/pkg/routes/api/shared/testing.go @@ -0,0 +1,92 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" +) + +// dependentTestingTables lists tables that reference a reset table by ID and +// must be truncated alongside it. Without foreign key cascades, stale rows +// would persist and pollute subsequent tests that reuse the same +// auto-increment IDs. +var dependentTestingTables = map[string][]string{ + "users": {"notifications"}, +} + +// ReplaceTableContents resets a single table to the provided rows for the e2e +// testing endpoint and returns the table's resulting contents. When truncate is +// true the table (and any dependent tables) is emptied first; otherwise the rows +// are restored on top of existing data. Callers must already have verified the +// testing token. +func ReplaceTableContents(table string, content []map[string]interface{}, truncate bool) ([]map[string]interface{}, error) { + // Wait for all async event handlers from the previous test to complete + // before modifying the database. Without this, handlers hold SQLite + // connections and starve this request's truncate/insert operations. + events.WaitForPendingHandlers() + + var err error + if truncate { + for _, dep := range dependentTestingTables[table] { + if err = db.RestoreAndTruncate(dep, nil); err != nil { + return nil, err + } + } + err = db.RestoreAndTruncate(table, content) + } else { + err = db.Restore(table, content) + } + if err != nil { + return nil, err + } + + // License state is cached at startup; re-apply so tests take effect without a restart. + if table == "license_status" { + if err := license.ReloadFromCache(); err != nil { + return nil, err + } + } + + s := db.NewSession() + defer s.Close() + data := []map[string]interface{}{} + if err := s.Table(table).Find(&data); err != nil { + return nil, err + } + return data, nil +} + +// TruncateAllTestingTables empties every Vikunja table for the e2e testing +// endpoint. Callers must already have verified the testing token. +func TruncateAllTestingTables() error { + events.WaitForPendingHandlers() + + if err := db.TruncateAllTables(); err != nil { + return err + } + + // Reload after truncate; otherwise features enabled by a prior test outlive + // the now-empty license_status table. A reload failure here is non-fatal — + // the truncate already succeeded — so it is logged and swallowed. + if err := license.ReloadFromCache(); err != nil { + log.Errorf("Error reloading license after truncate: %v", err) + } + return nil +} diff --git a/pkg/routes/api/v1/admin/overview.go b/pkg/routes/api/v1/admin/overview.go index 3911e31be..6c5b71858 100644 --- a/pkg/routes/api/v1/admin/overview.go +++ b/pkg/routes/api/v1/admin/overview.go @@ -20,77 +20,27 @@ import ( "net/http" "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + "github.com/labstack/echo/v5" ) -type ShareCounts struct { - LinkShares int64 `json:"link_shares"` - TeamShares int64 `json:"team_shares"` - UserShares int64 `json:"user_shares"` -} - -type Overview struct { - Users int64 `json:"users"` - Projects int64 `json:"projects"` - Tasks int64 `json:"tasks"` - Teams int64 `json:"teams"` - Shares ShareCounts `json:"shares"` - License license.Info `json:"license"` -} - // GetOverview returns aggregate instance counts and metadata. // @Summary Admin overview // @Description Returns per-instance counts (users, projects, shares) plus version and license info. Instance-admin only, gated by the admin_panel feature. // @tags admin // @Produce json // @Security JWTKeyAuth -// @Success 200 {object} admin.Overview +// @Success 200 {object} models.Overview // @Failure 404 {object} web.HTTPError // @Router /admin/overview [get] func GetOverview(c *echo.Context) error { s := db.NewSession() defer s.Close() - users, err := s.Table("users").Count() + overview, err := models.BuildOverview(s) if err != nil { return err } - projects, err := s.Table("projects").Count() - if err != nil { - return err - } - tasks, err := s.Table("tasks").Count() - if err != nil { - return err - } - teams, err := s.Table("teams").Count() - if err != nil { - return err - } - linkShares, err := s.Table("link_shares").Count() - if err != nil { - return err - } - teamShares, err := s.Table("team_projects").Count() - if err != nil { - return err - } - userShares, err := s.Table("users_projects").Count() - if err != nil { - return err - } - - return c.JSON(http.StatusOK, Overview{ - Users: users, - Projects: projects, - Tasks: tasks, - Teams: teams, - Shares: ShareCounts{ - LinkShares: linkShares, - TeamShares: teamShares, - UserShares: userShares, - }, - License: license.CurrentInfo(), - }) + return c.JSON(http.StatusOK, overview) } diff --git a/pkg/routes/api/v1/admin/user_create.go b/pkg/routes/api/v1/admin/user_create.go index 5ba455579..bedddef58 100644 --- a/pkg/routes/api/v1/admin/user_create.go +++ b/pkg/routes/api/v1/admin/user_create.go @@ -20,28 +20,14 @@ import ( "errors" "net/http" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -// CreateUserBody wraps user.APIUserPassword with admin-only fields. -type CreateUserBody struct { - // The full name of the new user. Optional. - Name string `json:"name"` - // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. - Language string `json:"language" valid:"language"` - user.APIUserPassword - // Mark the new user as an instance admin. - IsAdmin bool `json:"is_admin"` - // Activate the new user immediately without email confirmation. - SkipEmailConfirm bool `json:"skip_email_confirm"` -} - // CreateUser provisions a new account on behalf of an instance admin. // @Summary Create a user (admin) // @Description Create a new local user account. Respects the admin-only fields `is_admin` and `skip_email_confirm`. The public registration toggle is bypassed. @@ -49,12 +35,12 @@ type CreateUserBody struct { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param body body admin.CreateUserBody true "The user to create" -// @Success 200 {object} admin.User +// @Param body body models.CreateUserBody true "The user to create" +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Router /admin/users [post] func CreateUser(c *echo.Context) error { - body := &CreateUserBody{} + body := &models.CreateUserBody{} if err := c.Bind(body); err != nil { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } @@ -69,52 +55,15 @@ func CreateUser(c *echo.Context) error { s := db.NewSession() defer s.Close() - newUser, err := models.RegisterUser(s, &user.User{ - Username: body.Username, - Password: body.Password, - Email: body.Email, - Name: body.Name, - Language: body.Language, - }) + newUser, err := models.CreateUserAsAdmin(s, body) if err != nil { _ = s.Rollback() return err } - if body.IsAdmin { - if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil { - _ = s.Rollback() - return err - } - newUser.IsAdmin = true - } - - // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation. - if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() { - if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil { - _ = s.Rollback() - return err - } - newUser.Status = user.StatusActive - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Reload the user so the returned status reflects what was actually persisted - // (e.g. StatusEmailConfirmationRequired on mail-enabled instances). - rs := db.NewSession() - defer rs.Close() - newUser, err = user.GetUserByID(rs, newUser.ID) - if err != nil { - return err - } - providers, err := openid.GetAllProviders() if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(newUser, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(newUser, providers)) } diff --git a/pkg/routes/api/v1/admin/users.go b/pkg/routes/api/v1/admin/users.go index f9392b772..5117b9e46 100644 --- a/pkg/routes/api/v1/admin/users.go +++ b/pkg/routes/api/v1/admin/users.go @@ -18,52 +18,13 @@ package admin import ( "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/xorm" ) -// User re-exposes fields hidden by the default user.User JSON view. -type User struct { - *user.User - IsAdmin bool `json:"is_admin"` - Status user.Status `json:"status"` - Issuer string `json:"issuer"` - Subject string `json:"subject,omitempty"` - AuthProvider string `json:"auth_provider,omitempty"` -} - -func newAdminUser(u *user.User, providers []*openid.Provider) *User { - return &User{ - User: u, - IsAdmin: u.IsAdmin, - Status: u.Status, - Issuer: u.Issuer, - Subject: u.Subject, - AuthProvider: resolveAuthProvider(u, providers), - } -} - -func resolveAuthProvider(u *user.User, providers []*openid.Provider) string { - switch u.Issuer { - case "", user.IssuerLocal: - return "" - case user.IssuerLDAP: - return "LDAP" - } - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - continue - } - if issuerURL == u.Issuer { - return provider.Name - } - } - return u.Issuer -} - // UserList backs the admin list-users route via handler.ReadAllWeb; only ReadAll is used. type UserList struct { web.CRUDable `xorm:"-" json:"-"` @@ -79,7 +40,7 @@ type UserList struct { // @Param s query string false "Search string matched against username and email." // @Param page query int false "Page number, defaults to 1." // @Param per_page query int false "Items per page, defaults to the service setting." -// @Success 200 {array} admin.User +// @Success 200 {array} shared.AdminUser // @Failure 404 {object} web.HTTPError // @Router /admin/users [get] func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPage int) (interface{}, int, int64, error) { @@ -106,9 +67,9 @@ func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPa return nil, 0, 0, err } - out := make([]*User, 0, len(users)) + out := make([]*shared.AdminUser, 0, len(users)) for _, u := range users { - out = append(out, newAdminUser(u, providers)) + out = append(out, shared.NewAdminUser(u, providers)) } return out, len(out), totalCount, nil } diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go index 5f31b269f..195bb2092 100644 --- a/pkg/routes/api/v1/admin/users_admin.go +++ b/pkg/routes/api/v1/admin/users_admin.go @@ -23,7 +23,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" + "github.com/labstack/echo/v5" ) @@ -41,7 +43,7 @@ type IsAdminPatch struct { // @Security JWTKeyAuth // @Param id path int true "User ID" // @Param body body admin.IsAdminPatch true "New admin value" -// @Success 200 {object} admin.User +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Failure 404 {object} web.HTTPError // @Router /admin/users/{id}/admin [patch] @@ -63,24 +65,8 @@ func PatchAdmin(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) + target, err := models.SetUserAdminFlag(s, id, *body.IsAdmin) if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - if !*body.IsAdmin { - if err := user.GuardLastAdmin(s, target); err != nil { - _ = s.Rollback() - return err - } - } - - target.IsAdmin = *body.IsAdmin - if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { _ = s.Rollback() return err } @@ -92,5 +78,5 @@ func PatchAdmin(c *echo.Context) error { if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(target, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers)) } diff --git a/pkg/routes/api/v1/admin/users_mgmt.go b/pkg/routes/api/v1/admin/users_mgmt.go index 2e95d88b5..1e72fa173 100644 --- a/pkg/routes/api/v1/admin/users_mgmt.go +++ b/pkg/routes/api/v1/admin/users_mgmt.go @@ -23,7 +23,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" + "github.com/labstack/echo/v5" ) @@ -41,7 +43,7 @@ type StatusPatch struct { // @Security JWTKeyAuth // @Param id path int true "User ID" // @Param body body admin.StatusPatch true "Status" -// @Success 200 {object} admin.User +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Failure 404 {object} web.HTTPError // @Router /admin/users/{id}/status [patch] @@ -65,24 +67,8 @@ func PatchStatus(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) + target, err := models.SetUserStatusAsAdmin(s, id, newStatus) if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - // Any non-Active status blocks login, so moving an admin out of Active is equivalent to demotion. - if target.IsAdmin && newStatus != user.StatusActive { - if err := user.GuardLastAdmin(s, target); err != nil { - _ = s.Rollback() - return err - } - } - - if err := user.SetUserStatus(s, target, newStatus); err != nil { _ = s.Rollback() return err } @@ -90,13 +76,11 @@ func PatchStatus(c *echo.Context) error { return err } - // Refresh locally since GetUserByID refuses disabled accounts. - target.Status = newStatus providers, err := openid.GetAllProviders() if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(target, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers)) } // DeleteUser removes a user either immediately or through the self-deletion flow. @@ -128,32 +112,10 @@ func DeleteUser(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) - if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - if err := user.GuardLastAdmin(s, target); err != nil { + if err := models.DeleteUserAsAdmin(s, id, mode); err != nil { _ = s.Rollback() return err } - - if mode == "now" { - if err := models.DeleteUser(s, target); err != nil { - _ = s.Rollback() - return err - } - } else { - if err := user.RequestDeletion(s, target); err != nil { - _ = s.Rollback() - return err - } - } - if err := s.Commit(); err != nil { return err } diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 0e0a64ff2..87891ff15 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -19,151 +19,18 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/license" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/modules/auth/openid" - csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" - microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" - "code.vikunja.io/api/pkg/modules/migration/ticktick" - "code.vikunja.io/api/pkg/modules/migration/todoist" - "code.vikunja.io/api/pkg/modules/migration/trello" - vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" - "code.vikunja.io/api/pkg/modules/migration/wekan" - "code.vikunja.io/api/pkg/version" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type vikunjaInfos struct { - Version string `json:"version"` - FrontendURL string `json:"frontend_url"` - Motd string `json:"motd"` - LinkSharingEnabled bool `json:"link_sharing_enabled"` - MaxFileSize string `json:"max_file_size"` - MaxItemsPerPage int `json:"max_items_per_page"` - AvailableMigrators []string `json:"available_migrators"` - TaskAttachmentsEnabled bool `json:"task_attachments_enabled"` - EnabledBackgroundProviders []string `json:"enabled_background_providers"` - TotpEnabled bool `json:"totp_enabled"` - Legal legalInfo `json:"legal"` - CaldavEnabled bool `json:"caldav_enabled"` - AuthInfo authInfo `json:"auth"` - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - UserDeletionEnabled bool `json:"user_deletion_enabled"` - TaskCommentsEnabled bool `json:"task_comments_enabled"` - DemoModeEnabled bool `json:"demo_mode_enabled"` - WebhooksEnabled bool `json:"webhooks_enabled"` - PublicTeamsEnabled bool `json:"public_teams_enabled"` - AllowIconChanges bool `json:"allow_icon_changes"` - EnabledProFeatures []license.Feature `json:"enabled_pro_features"` -} - -type authInfo struct { - Local localAuthInfo `json:"local"` - Ldap ldapAuthInfo `json:"ldap"` - OpenIDConnect openIDAuthInfo `json:"openid_connect"` -} - -type localAuthInfo struct { - Enabled bool `json:"enabled"` - RegistrationEnabled bool `json:"registration_enabled"` -} - -type ldapAuthInfo struct { - Enabled bool `json:"enabled"` -} - -type openIDAuthInfo struct { - Enabled bool `json:"enabled"` - Providers []*openid.Provider `json:"providers"` -} - -type legalInfo struct { - ImprintURL string `json:"imprint_url"` - PrivacyPolicyURL string `json:"privacy_policy_url"` -} - // Info is the handler to get infos about this vikunja instance // @Summary Info // @Description Returns the version, frontendurl, motd and various settings of Vikunja // @tags service // @Produce json -// @Success 200 {object} v1.vikunjaInfos +// @Success 200 {object} shared.VikunjaInfos // @Router /info [get] func Info(c *echo.Context) error { - info := vikunjaInfos{ - Version: version.Version, - FrontendURL: config.ServicePublicURL.GetString(), - Motd: config.ServiceMotd.GetString(), - LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(), - MaxFileSize: config.FilesMaxSize.GetString(), - MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(), - TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), - TotpEnabled: config.ServiceEnableTotp.GetBool(), - CaldavEnabled: config.ServiceEnableCaldav.GetBool(), - EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), - UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(), - TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(), - DemoModeEnabled: config.ServiceDemoMode.GetBool(), - WebhooksEnabled: config.WebhooksEnabled.GetBool(), - PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), - AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), - EnabledProFeatures: license.EnabledProFeatures(), - AvailableMigrators: []string{ - (&vikunja_file.FileMigrator{}).Name(), - (&ticktick.Migrator{}).Name(), - (&wekan.Migrator{}).Name(), - (&csvmigrator.Migrator{}).Name(), - }, - Legal: legalInfo{ - ImprintURL: config.LegalImprintURL.GetString(), - PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), - }, - AuthInfo: authInfo{ - Local: localAuthInfo{ - Enabled: config.AuthLocalEnabled.GetBool(), - RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(), - }, - Ldap: ldapAuthInfo{ - Enabled: config.AuthLdapEnabled.GetBool(), - }, - OpenIDConnect: openIDAuthInfo{ - Enabled: config.AuthOpenIDEnabled.GetBool(), - }, - }, - } - - providers, err := openid.GetAllProviders() - if err != nil { - log.Errorf("Error while getting openid providers for /info: %s", err) - // No return here to not break /info - } - - info.AuthInfo.OpenIDConnect.Providers = providers - - // Migrators - if config.MigrationTodoistEnable.GetBool() { - m := &todoist.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationTrelloEnable.GetBool() { - m := &trello.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationMicrosoftTodoEnable.GetBool() { - m := µsofttodo.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - - if config.BackgroundsEnabled.GetBool() { - if config.BackgroundsUploadEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload") - } - if config.BackgroundsUnsplashEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash") - } - } - - return c.JSON(http.StatusOK, info) + return c.JSON(http.StatusOK, shared.BuildInfo()) } diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index 9e20a94f8..f4ca79ed0 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -19,20 +19,11 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/routes/api/shared" - "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/auth" "github.com/labstack/echo/v5" ) -// LinkShareToken represents a link share auth token with extra infos about the actual link share -type LinkShareToken struct { - auth.Token - *models.LinkSharing - ProjectID int64 `json:"project_id"` -} - // LinkShareAuth represents everything required to authenticate a link share type LinkShareAuth struct { Hash string `param:"share" json:"-"` @@ -53,36 +44,14 @@ type LinkShareAuth struct { // @Router /shares/{share}/auth [post] func AuthenticateLinkShare(c *echo.Context) error { sh := &LinkShareAuth{} - err := c.Bind(sh) + if err := c.Bind(sh); err != nil { + return err + } + + token, err := shared.AuthenticateLinkShare(sh.Hash, sh.Password) if err != nil { return err } - s := db.NewSession() - defer s.Close() - - share, err := models.GetLinkShareByHash(s, sh.Hash) - if err != nil { - return err - } - - if share.SharingType == models.SharingTypeWithPassword { - err := models.VerifyLinkSharePassword(share, sh.Password) - if err != nil { - return err - } - } - - t, err := auth.NewLinkShareJWTAuthtoken(share) - if err != nil { - return err - } - - share.Password = "" - - return c.JSON(http.StatusOK, LinkShareToken{ - Token: auth.Token{Token: t}, - LinkSharing: share, - ProjectID: share.ProjectID, - }) + return c.JSON(http.StatusOK, token) } diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index eb92945d1..6a4662ae2 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -21,10 +21,11 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/modules/auth/ldap" - "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/routes/api/shared" user2 "code.vikunja.io/api/pkg/user" "github.com/golang-jwt/jwt/v5" @@ -49,84 +50,13 @@ func Login(c *echo.Context) (err error) { return c.JSON(http.StatusBadRequest, models.Message{Message: "Please provide a username and password."}) } - s := db.NewSession() - defer s.Close() - - var user *user2.User - if config.AuthLdapEnabled.GetBool() { - user, err = ldap.AuthenticateUserInLDAP(s, u.Username, u.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString()) - if err != nil && !user2.IsErrWrongUsernameOrPassword(err) { - _ = s.Rollback() - return err - } - } - - if user == nil { - // Check if the user is a bot before attempting password verification, - // because bots have no password hash and bcrypt would fail with a - // misleading error. - existingUser, lookupErr := user2.GetUserByUsername(s, u.Username) - if lookupErr == nil && existingUser.IsBot() { - _ = s.Rollback() - return &user2.ErrAccountIsBot{UserID: existingUser.ID} - } - - // This allows us to still have local users while ldap is enabled - user, err = user2.CheckUserCredentials(s, &u) - if err != nil { - _ = s.Rollback() - return err - } - } - - if user.Status == user2.StatusDisabled || user.Status == user2.StatusAccountLocked { - _ = s.Rollback() - return &user2.ErrAccountDisabled{UserID: user.ID} - } - - totpEnabled, err := user2.TOTPEnabledForUser(s, user) + user, err := shared.AuthenticateUserCredentials(c.Request().Context(), &u) if err != nil { - _ = s.Rollback() - return err - } - - if totpEnabled { - if u.TOTPPasscode == "" { - _ = s.Rollback() - return user2.ErrInvalidTOTPPasscode{} - } - - _, err = user2.ValidateTOTPPasscode(s, &user2.TOTPPasscode{ - User: user, - Passcode: u.TOTPPasscode, - }) - if err != nil { - // Rollback before HandleFailedTOTPAuth so its dedicated session - // can acquire a write lock on SQLite shared-cache. The lockout - // write is decoupled from this handler's transaction — see - // GHSA-fgfv-pv97-6cmj. - _ = s.Rollback() - if user2.IsErrInvalidTOTPPasscode(err) { - user2.HandleFailedTOTPAuth(user) - } - return err - } - } - - if err := keyvalue.Del(user.GetFailedTOTPAttemptsKey()); err != nil { - return err - } - if err := keyvalue.Del(user.GetFailedPasswordAttemptsKey()); err != nil { - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() return err } // Create token - return auth.NewUserAuthTokenResponse(user, c, u.LongToken) + return auth.NewUserAuthTokenResponse(user, c, u.LongToken, nil) } // RenewToken renews a link share token only. User tokens must use @@ -220,42 +150,52 @@ func RefreshToken(c *echo.Context) (err error) { return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken}) } +type LogoutResponse struct { + Message string `json:"message"` + // RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions. + OIDCLogoutURL string `json:"oidc_logout_url,omitempty"` +} + // Logout deletes the current session from the server. // @Summary Logout -// @Description Destroys the current session and clears the refresh token cookie. +// @Description Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too. // @tags auth // @Produce json -// @Success 200 {object} models.Message "Successfully logged out." +// @Success 200 {object} v1.LogoutResponse "Successfully logged out." // @Router /user/logout [post] func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) var sid string + var userID int64 if raw := c.Get("user"); raw != nil { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) + // Only user tokens carry a sid, but check the type explicitly + // so a link share id can never be logged as a user id. + if typ, ok := claims["type"].(float64); ok && int(typ) == auth.AuthTypeUser { + if id, ok := claims["id"].(float64); ok { + userID = int64(id) + } + } } } } - if sid == "" { - return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) - } - - s := db.NewSession() - defer s.Close() - - _, err = s.Where("id = ?", sid).Delete(&models.Session{}) + oidcLogoutURL, err := shared.LogoutSession(sid) if err != nil { - _ = s.Rollback() return err } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err + if userID != 0 { + if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil { + log.Errorf("Could not dispatch logout event: %s", err) + } } - return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) + return c.JSON(http.StatusOK, LogoutResponse{ + Message: "Successfully logged out.", + OIDCLogoutURL: oidcLogoutURL, + }) } diff --git a/pkg/routes/api/v1/task_attachment.go b/pkg/routes/api/v1/task_attachment.go index dd6478703..68197a3bf 100644 --- a/pkg/routes/api/v1/task_attachment.go +++ b/pkg/routes/api/v1/task_attachment.go @@ -18,43 +18,16 @@ package v1 import ( "errors" - "io" - "mime" "net/http" - "strconv" - "strings" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" auth2 "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/web" + webfiles "code.vikunja.io/api/pkg/web/files" "github.com/labstack/echo/v5" ) -// attachmentUploadError represents a structured error for attachment upload failures -type attachmentUploadError struct { - Code int `json:"code,omitempty"` - Message string `json:"message"` -} - -// toAttachmentUploadError converts an error to a structured attachmentUploadError -func toAttachmentUploadError(err error) attachmentUploadError { - // Try to get structured error info from HTTPErrorProcessor - if httpErr, ok := err.(web.HTTPErrorProcessor); ok { - errDetails := httpErr.HTTPError() - return attachmentUploadError{ - Code: errDetails.Code, - Message: errDetails.Message, - } - } - // Fall back to just the error message - return attachmentUploadError{ - Message: err.Error(), - } -} - // UploadTaskAttachment handles everything needed for the upload of a task attachment // @Summary Upload a task attachment // @Description Upload a task attachment. You can pass multiple files with the files form param. @@ -76,7 +49,6 @@ func UploadTaskAttachment(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err) } - // Permissions check auth, err := auth2.GetAuthFromClaims(c) if err != nil { return err @@ -85,15 +57,6 @@ func UploadTaskAttachment(c *echo.Context) error { s := db.NewSession() defer s.Close() - can, err := taskAttachment.CanCreate(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !can { - return echo.ErrForbidden - } - // Multipart form form, err := c.MultipartForm() if err != nil { @@ -104,31 +67,23 @@ func UploadTaskAttachment(c *echo.Context) error { return err } - type result struct { - Errors []attachmentUploadError `json:"errors"` - Success []*models.TaskAttachment `json:"success"` - } - r := &result{} fileHeaders := form.File["files"] + uploads := make([]*models.AttachmentToUpload, 0, len(fileHeaders)) + var openErrors []error for _, file := range fileHeaders { - // We create a new attachment object here to have a clean start - ta := &models.TaskAttachment{ - TaskID: taskAttachment.TaskID, - } - f, err := file.Open() if err != nil { - r.Errors = append(r.Errors, toAttachmentUploadError(err)) + openErrors = append(openErrors, err) continue } defer f.Close() + uploads = append(uploads, &models.AttachmentToUpload{Reader: f, Filename: file.Filename, Size: uint64(file.Size)}) + } - err = ta.NewAttachment(s, f, file.Filename, uint64(file.Size), auth) - if err != nil { - r.Errors = append(r.Errors, toAttachmentUploadError(err)) - continue - } - r.Success = append(r.Success, ta) + success, failures, err := models.UploadTaskAttachments(s, auth, taskAttachment.TaskID, uploads) + if err != nil { + _ = s.Rollback() + return err } if err := s.Commit(); err != nil { @@ -136,7 +91,7 @@ func UploadTaskAttachment(c *echo.Context) error { return err } - return c.JSON(http.StatusOK, r) + return c.JSON(http.StatusOK, webfiles.BuildUploadResult(success, append(openErrors, failures...))) } // GetTaskAttachment returns a task attachment to download for the user @@ -160,7 +115,6 @@ func GetTaskAttachment(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err) } - // Permissions check auth, err := auth2.GetAuthFromClaims(c) if err != nil { return err @@ -169,36 +123,11 @@ func GetTaskAttachment(c *echo.Context) error { s := db.NewSession() defer s.Close() - can, _, err := taskAttachment.CanRead(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !can { - return echo.ErrForbidden - } - - // Get the attachment incl file - err = taskAttachment.ReadOne(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - - // Open the file so its content is available for preview generation and download - err = taskAttachment.File.LoadFileByID() - if err != nil { - _ = s.Rollback() - return err - } - - // If the preview query parameter is set, get the preview (cached or generate) previewSize := models.GetPreviewSizeFromString(c.QueryParam("preview_size")) - if previewSize != models.PreviewSizeUnknown && strings.HasPrefix(taskAttachment.File.Mime, "image") { - previewFileBytes := taskAttachment.GetPreview(previewSize) - if previewFileBytes != nil { - return c.Blob(http.StatusOK, "image/png", previewFileBytes) - } + attachment, preview, err := models.LoadTaskAttachmentForDownload(s, auth, taskAttachment.TaskID, taskAttachment.ID, previewSize) + if err != nil { + _ = s.Rollback() + return err } if err := s.Commit(); err != nil { @@ -206,36 +135,6 @@ func GetTaskAttachment(c *echo.Context) error { return err } - mimeToReturn := taskAttachment.File.Mime - if mimeToReturn == "" { - mimeToReturn = "application/octet-stream" - } - - c.Response().Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{ - "filename": taskAttachment.File.Name, - })) - c.Response().Header().Set("Content-Type", mimeToReturn) - c.Response().Header().Set("Content-Length", strconv.FormatUint(taskAttachment.File.Size, 10)) - c.Response().Header().Set("Last-Modified", taskAttachment.File.Created.UTC().Format(http.TimeFormat)) - // Override the global no-store directive so browsers can cache attachments. - // no-cache allows caching but requires revalidation via If-Modified-Since. - c.Response().Header().Set("Cache-Control", "no-cache") - - if config.FilesType.GetString() == "s3" { - // Check If-Modified-Since and return 304 if the file hasn't changed. - // http.ServeContent handles this automatically for local files. - if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" { - if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !taskAttachment.File.Created.UTC().After(t) { - return c.NoContent(http.StatusNotModified) - } - } - - // s3 files cannot use http.ServeContent as it requires a Seekable file - // so we stream the file content directly to the response - _, err = io.Copy(c.Response(), taskAttachment.File.File) - return err - } - - http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File.(io.ReadSeeker)) + webfiles.WriteAttachmentDownload(c.Response(), c.Request(), attachment, preview) return nil } diff --git a/pkg/routes/api/v1/testing.go b/pkg/routes/api/v1/testing.go index 98f5aeca1..62d5f5206 100644 --- a/pkg/routes/api/v1/testing.go +++ b/pkg/routes/api/v1/testing.go @@ -22,10 +22,8 @@ import ( "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/license" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) @@ -63,36 +61,8 @@ func HandleTesting(c *echo.Context) error { }) } - // Wait for all async event handlers from the previous test to complete - // before modifying the database. Without this, handlers hold SQLite - // connections and starve this request's truncate/insert operations. - events.WaitForPendingHandlers() - truncate := c.QueryParam("truncate") - if truncate == "true" || truncate == "" { - // When truncating certain tables, also truncate dependent tables - // whose rows reference the truncated table by user/entity ID. - // Without foreign key cascades, stale rows would persist and - // pollute subsequent tests that reuse the same auto-increment IDs. - dependentTables := map[string][]string{ - "users": {"notifications"}, - } - if deps, ok := dependentTables[table]; ok { - for _, dep := range deps { - if err = db.RestoreAndTruncate(dep, nil); err != nil { - log.Errorf("Error truncating dependent table %s: %v", dep, err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - } - } - err = db.RestoreAndTruncate(table, content) - } else { - err = db.Restore(table, content) - } - + data, err := shared.ReplaceTableContents(table, content, truncate == "true" || truncate == "") if err != nil { log.Errorf("Error replacing table data: %v", err) return c.JSON(http.StatusInternalServerError, map[string]interface{}{ @@ -101,29 +71,6 @@ func HandleTesting(c *echo.Context) error { }) } - // License state is cached at startup; re-apply so tests take effect without a restart. - if table == "license_status" { - if err := license.ReloadFromCache(); err != nil { - log.Errorf("Error reloading license from seeded cache: %v", err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - } - - s := db.NewSession() - defer s.Close() - data := []map[string]interface{}{} - err = s.Table(table).Find(&data) - if err != nil { - log.Errorf("Error fetching table data: %v", err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - return c.JSON(http.StatusCreated, data) } @@ -142,9 +89,7 @@ func HandleTestingTruncateAll(c *echo.Context) error { return echo.ErrForbidden } - events.WaitForPendingHandlers() - - if err := db.TruncateAllTables(); err != nil { + if err := shared.TruncateAllTestingTables(); err != nil { log.Errorf("Error truncating all tables: %v", err) return c.JSON(http.StatusInternalServerError, map[string]interface{}{ "error": true, @@ -152,11 +97,6 @@ func HandleTestingTruncateAll(c *echo.Context) error { }) } - // Reload after truncate; otherwise features enabled by a prior test outlive the now-empty license_status table. - if err := license.ReloadFromCache(); err != nil { - log.Errorf("Error reloading license after truncate: %v", err) - } - return c.JSON(http.StatusOK, map[string]string{ "message": "ok", }) diff --git a/pkg/routes/api/v1/user_confirm_email.go b/pkg/routes/api/v1/user_confirm_email.go index e01865103..254d4142a 100644 --- a/pkg/routes/api/v1/user_confirm_email.go +++ b/pkg/routes/api/v1/user_confirm_email.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -44,17 +43,7 @@ func UserConfirmEmail(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No token provided.").Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.ConfirmEmail(s, &emailConfirm) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ConfirmEmail(&emailConfirm); err != nil { return err } diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 3c07c9ebc..b01b1fdf3 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -19,14 +19,11 @@ package v1 import ( "io" "net/http" - "os" "strconv" - "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" @@ -97,7 +94,7 @@ func RequestUserDataExport(c *echo.Context) error { return err } - events.DispatchPending(s) + events.DispatchPending(c.Request().Context(), s) return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested data export. We will send you an email when it's ready."}) } @@ -127,28 +124,18 @@ func DownloadUserDataExport(c *echo.Context) error { return err } - // Check if user has an export file - exportNotFoundError := echo.NewHTTPError(http.StatusNotFound, "No user data export found.") - if u.ExportFileID == 0 { - return exportNotFoundError + exportFile, err := models.GetUserDataExportFile(u) + if err != nil { + if models.IsErrUserDataExportDoesNotExist(err) { + return echo.NewHTTPError(http.StatusNotFound, "No user data export found.") + } + return err } + defer func() { _ = exportFile.File.Close() }() - // Download - exportFile := &files.File{ID: u.ExportFileID} - err = exportFile.LoadFileMetaByID() - if err != nil { - if files.IsErrFileDoesNotExist(err) { - return exportNotFoundError - } - return err - } - err = exportFile.LoadFileByID() - if err != nil { - if os.IsNotExist(err) { - return exportNotFoundError - } - return err - } + // Downloads must never be cached; no-cache overrides the global no-store + // directive while still allowing revalidation. + c.Response().Header().Set("Cache-Control", "no-cache") if config.FilesType.GetString() == "s3" { c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"") @@ -163,19 +150,12 @@ func DownloadUserDataExport(c *echo.Context) error { return nil } -type UserExportStatus struct { - ID int64 `json:"id"` - Size uint64 `json:"size"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` -} - // GetUserExportStatus returns metadata about the current user export if it exists // @Summary Get current user data export // @tags user // @Produce json // @Security JWTKeyAuth -// @Success 200 {object} v1.UserExportStatus +// @Success 200 {object} models.UserExportStatus // @Router /user/export [get] func GetUserExportStatus(c *echo.Context) error { s := db.NewSession() @@ -186,20 +166,12 @@ func GetUserExportStatus(c *echo.Context) error { return err } - if u.ExportFileID == 0 { - return c.JSON(http.StatusOK, struct{}{}) - } - - exportFile := &files.File{ID: u.ExportFileID} - if err := exportFile.LoadFileMetaByID(); err != nil { + status, err := models.GetUserDataExportStatus(u) + if err != nil { return err } - - status := UserExportStatus{ - ID: exportFile.ID, - Size: exportFile.Size, - Created: exportFile.Created, - Expires: exportFile.Created.Add(7 * 24 * time.Hour), + if status == nil { + return c.JSON(http.StatusOK, struct{}{}) } return c.JSON(http.StatusOK, status) diff --git a/pkg/routes/api/v1/user_list.go b/pkg/routes/api/v1/user_list.go index db4a777a0..6bc6e392f 100644 --- a/pkg/routes/api/v1/user_list.go +++ b/pkg/routes/api/v1/user_list.go @@ -52,17 +52,12 @@ func UserList(c *echo.Context) error { return err } - users, err := user.ListUsers(s, search, currentUser, nil) + users, err := user.SearchUsers(s, search, currentUser) if err != nil { _ = s.Rollback() return err } - // Obfuscate the mailadresses - for in := range users { - users[in].Email = "" - } - return c.JSON(http.StatusOK, users) } @@ -98,15 +93,6 @@ func ListUsersForProject(c *echo.Context) error { s := db.NewSession() defer s.Close() - canRead, _, err := project.CanRead(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !canRead { - return echo.ErrForbidden - } - currentUser, err := user.GetCurrentUser(c) if err != nil { _ = s.Rollback() @@ -114,11 +100,14 @@ func ListUsersForProject(c *echo.Context) error { } search := c.QueryParam("s") - users, err := models.ListUsersFromProject(s, &project, currentUser, search) + users, canRead, err := models.SearchUsersForProject(s, &project, auth, currentUser, search) if err != nil { _ = s.Rollback() return err } + if !canRead { + return echo.ErrForbidden + } if err := s.Commit(); err != nil { _ = s.Rollback() diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go index b91a28a7a..6c8090ba0 100644 --- a/pkg/routes/api/v1/user_password_reset.go +++ b/pkg/routes/api/v1/user_password_reset.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -49,22 +48,7 @@ func UserResetPassword(c *echo.Context) error { return err } - s := db.NewSession() - defer s.Close() - - userID, err := user.ResetPassword(s, &pwReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, userID); err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ResetPassword(&pwReset); err != nil { return err } @@ -93,17 +77,7 @@ func UserRequestResetPasswordToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()).Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.RequestUserPasswordResetTokenByEmail(s, &pwTokenReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.RequestPasswordResetToken(&pwTokenReset); err != nil { return err } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 9db52c88a..e9a90dc2f 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -21,20 +21,15 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type UserRegister struct { - // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. - Language string `json:"language" valid:"language"` - user.APIUserPassword -} +// UserRegister is an alias for the shared registration input, kept so the v1 +// swagger annotation and any existing imports still resolve. +type UserRegister = shared.UserRegister // RegisterUser is the register handler // @Summary Register @@ -68,32 +63,10 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - s := db.NewSession() - defer s.Close() - - newUser, err := models.RegisterUser(s, &user.User{ - Username: userIn.Username, - Password: userIn.Password, - Email: userIn.Email, - Language: userIn.Language, - }) + newUser, err := shared.RegisterUser(c.Request().Context(), userIn) if err != nil { - _ = s.Rollback() return err } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Bust the cached user count so the new registration shows up in metrics - // immediately instead of after the regular cache expiry. - if config.MetricsEnabled.GetBool() { - if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { - log.Errorf("Could not invalidate user count metric: %s", err) - } - } - return c.JSON(http.StatusOK, newUser) } diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 2efa9c0f0..049330411 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -26,7 +26,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/avatar" user2 "code.vikunja.io/api/pkg/user" ) @@ -36,35 +35,6 @@ type UserAvatarProvider struct { AvatarProvider string `json:"avatar_provider"` } -// UserSettings holds all user settings -type UserSettings struct { - // The new name of the current user. - Name string `json:"name"` - // If enabled, sends email reminders of tasks to the user. - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - // If true, this user can be found by their name or parts of it when searching for it. - DiscoverableByName bool `json:"discoverable_by_name"` - // If true, the user can be found when searching for their exact email. - DiscoverableByEmail bool `json:"discoverable_by_email"` - // If enabled, the user will get an email for their overdue tasks each morning. - OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` - // The time when the daily summary of overdue tasks will be sent via email. - OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` - // If a task is created without a specified project this value should be used. Applies - // to tasks made directly in API and from clients. - DefaultProjectID int64 `json:"default_project_id"` - // The day when the week starts for this user. 0 = sunday, 1 = monday, etc. - WeekStart int `json:"week_start" valid:"range(0|6)"` - // The user's language - Language string `json:"language"` - // The user's time zone. Used to send task reminders in the time zone of the user. - Timezone string `json:"timezone"` - // Additional settings only used by the frontend - FrontendSettings interface{} `json:"frontend_settings"` - // Additional settings links as provided by openid - ExtraSettingsLinks map[string]any `json:"extra_settings_links"` -} - // GetUserAvatarProvider returns the currently set user avatar // @Summary Return user avatar setting // @Description Returns the current user's avatar setting. @@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error { return err } - oldProvider := user.AvatarProvider - - user.AvatarProvider = uap.AvatarProvider - - _, err = user2.UpdateUser(s, user, false) - if err != nil { + if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil { _ = s.Rollback() return err } - if user.AvatarProvider == "initials" { - avatar.FlushAllCaches(user) - } - if err := s.Commit(); err != nil { _ = s.Rollback() return err } - if oldProvider != user.AvatarProvider { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) } @@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param avatar body UserSettings true "The updated user settings" +// @Param avatar body models.UserGeneralSettings true "The updated user settings" // @Success 200 {object} models.Message // @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 500 {object} models.Message "Internal server error." // @Router /user/settings/general [post] func UpdateGeneralUserSettings(c *echo.Context) error { - us := &UserSettings{} + us := &models.UserGeneralSettings{} err := c.Bind(us) if err != nil { var he *echo.HTTPError @@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name - - user.Name = us.Name - user.EmailRemindersEnabled = us.EmailRemindersEnabled - user.DiscoverableByEmail = us.DiscoverableByEmail - user.DiscoverableByName = us.DiscoverableByName - user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled - user.DefaultProjectID = us.DefaultProjectID - user.WeekStart = us.WeekStart - user.Language = us.Language - user.Timezone = us.Timezone - user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime - user.FrontendSettings = us.FrontendSettings - - _, err = user2.UpdateUser(s, user, true) - if err != nil { + if err := models.UpdateUserGeneralSettings(s, user, us); err != nil { _ = s.Rollback() return err } @@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - if invalidateAvatar { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."}) } diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index d5a391267..655b0fb5c 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -20,7 +20,7 @@ import ( "net/http" "time" - "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" @@ -34,11 +34,11 @@ import ( type UserWithSettings struct { user.User - Settings *UserSettings `json:"settings"` - DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` - IsLocalUser bool `json:"is_local_user"` - AuthProvider string `json:"auth_provider"` - IsAdmin bool `json:"is_admin"` + Settings *models.UserGeneralSettings `json:"settings"` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` + IsLocalUser bool `json:"is_local_user"` + AuthProvider string `json:"auth_provider"` + IsAdmin bool `json:"is_admin"` } // UserShow gets all information about the current user @@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error { } us := &UserWithSettings{ - User: *u, - Settings: &UserSettings{ - Name: u.Name, - EmailRemindersEnabled: u.EmailRemindersEnabled, - DiscoverableByName: u.DiscoverableByName, - DiscoverableByEmail: u.DiscoverableByEmail, - OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, - DefaultProjectID: u.DefaultProjectID, - WeekStart: u.WeekStart, - Language: u.Language, - Timezone: u.Timezone, - OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, - FrontendSettings: u.FrontendSettings, - ExtraSettingsLinks: u.ExtraSettingsLinks, - }, + User: *u, + Settings: models.NewUserGeneralSettings(u), DeletionScheduledAt: u.DeletionScheduledAt, IsLocalUser: u.Issuer == user.IssuerLocal, IsAdmin: u.IsAdmin, } - us.AuthProvider, err = getAuthProviderName(u) + us.AuthProvider, err = shared.GetAuthProviderName(u) if err != nil { return err } return c.JSON(http.StatusOK, us) } - -func getAuthProviderName(u *user.User) (name string, err error) { - if u.Issuer == user.IssuerLocal { - return "local", nil - } - - if u.Issuer == user.IssuerLDAP { - return "ldap", nil - } - - providers, err := openid.GetAllProviders() - if err != nil { - return "", err - } - - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - return "", err - } - if issuerURL == u.Issuer { - return provider.Name, nil - } - } - - return -} diff --git a/pkg/routes/api/v1/user_totp.go b/pkg/routes/api/v1/user_totp.go index e3c0ae076..a3c9fc8c4 100644 --- a/pkg/routes/api/v1/user_totp.go +++ b/pkg/routes/api/v1/user_totp.go @@ -17,10 +17,8 @@ package v1 import ( - "bytes" "errors" "fmt" - "image/jpeg" "net/http" "code.vikunja.io/api/pkg/db" @@ -202,14 +200,7 @@ func UserTOTPQrCode(c *echo.Context) error { } defer s.Close() - qrcode, err := user.GetTOTPQrCodeForUser(s, u) - if err != nil { - _ = s.Rollback() - return err - } - - buff := &bytes.Buffer{} - err = jpeg.Encode(buff, qrcode, nil) + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) if err != nil { _ = s.Rollback() return err @@ -220,7 +211,7 @@ func UserTOTPQrCode(c *echo.Context) error { return err } - return c.Blob(http.StatusOK, "image/jpeg", buff.Bytes()) + return c.Blob(http.StatusOK, "image/jpeg", qrcode) } // UserTOTP returns the current totp implementation if any is enabled. diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index ea1077075..7e03b250a 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{ - Username: emailUpdate.User.Username, - Password: emailUpdate.Password, - }) - if err != nil { - _ = s.Rollback() - return err - } - - err = user.UpdateEmail(s, emailUpdate) - if err != nil { + if err := user.ChangeUserEmail(c.Request().Context(), s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go index 0172a21ec..87b372aff 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error { return err } - if newPW.OldPassword == "" { - return user.ErrEmptyOldPassword{} - } - s := db.NewSession() defer s.Close() - // Check the current password - if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil { - _ = s.Rollback() - return err - } - - // Update the password - if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, doer.ID); err != nil { + if err := models.ChangeUserPassword(c.Request().Context(), s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go index 2203ab01d..9d424eb6e 100644 --- a/pkg/routes/api/v2/admin_projects.go +++ b/pkg/routes/api/v2/admin_projects.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" + "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/web/handler" @@ -31,6 +32,16 @@ type adminProjectListBody struct { Body Paginated[*models.Project] } +type adminProjectBody struct { + Body *models.Project +} + +// adminOwnerPatchBody reassigns a project's owner. owner_id is the only field; +// the regular project-update endpoint refuses owner changes. +type adminOwnerPatchBody struct { + OwnerID int64 `json:"owner_id" minimum:"1" doc:"The numeric ID of the user who should become the project's owner."` +} + // Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. func RegisterAdminProjectRoutes(api huma.API) { tags := []string{"admin"} @@ -43,6 +54,15 @@ func RegisterAdminProjectRoutes(api huma.API) { Path: "/admin/projects", Tags: tags, }, adminProjectsList) + + Register(api, huma.Operation{ + OperationID: "admin-projects-patch-owner", + Summary: "Reassign a project's owner (admin)", + Description: "Reassigns a project to a new owner — the admin-only escape hatch the regular update endpoint does not allow. The new owner must be an active account that is not scheduled for deletion. Restricted to instance admins on a licensed instance.", + Method: http.MethodPatch, + Path: "/admin/projects/{id}/owner", + Tags: tags, + }, adminProjectsPatchOwner) } func init() { AddRouteRegistrar(RegisterAdminProjectRoutes) } @@ -62,3 +82,28 @@ func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBo } return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } + +func adminProjectsPatchOwner(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the project."` + Body adminOwnerPatchBody +}) (*adminProjectBody, error) { + if in.ID < 1 { + return nil, translateDomainError(models.ErrProjectDoesNotExist{ID: in.ID}) + } + if in.Body.OwnerID < 1 { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid body"}) + } + + s := db.NewSession() + defer s.Close() + + p, err := models.ReassignProjectOwner(s, in.ID, in.Body.OwnerID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &adminProjectBody{Body: p}, nil +} diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go new file mode 100644 index 000000000..2724e433c --- /dev/null +++ b/pkg/routes/api/v2/admin_users.go @@ -0,0 +1,206 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type adminOverviewBody struct { + Body *models.Overview +} + +type adminUserBody struct { + Body *shared.AdminUser +} + +// adminIsAdminPatchBody uses a pointer so an omitted is_admin leaves the flag unchanged +// instead of silently demoting. +type adminIsAdminPatchBody struct { + IsAdmin *bool `json:"is_admin" doc:"New admin flag. Omitting it leaves the current value unchanged."` +} + +// adminStatusPatchBody uses a pointer so an omitted status leaves the account unchanged +// instead of silently reactivating. +type adminStatusPatchBody struct { + Status *user.Status `json:"status" doc:"New account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked). Omitting it leaves the current value unchanged."` +} + +// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. +func RegisterAdminUserRoutes(api huma.API) { + tags := []string{"admin"} + + Register(api, huma.Operation{ + OperationID: "admin-overview", + Summary: "Admin overview", + Description: "Returns per-instance counts (users, projects, tasks, teams, shares) plus the current license snapshot. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.", + Method: http.MethodGet, + Path: "/admin/overview", + Tags: tags, + }, adminOverview) + + Register(api, huma.Operation{ + OperationID: "admin-users-create", + Summary: "Create a user (admin)", + Description: "Creates a local user account, bypassing the public-registration toggle. Honours the admin-only is_admin and skip_email_confirm fields. Restricted to instance admins on a licensed instance.", + Method: http.MethodPost, + Path: "/admin/users", + Tags: tags, + }, adminUsersCreate) + + Register(api, huma.Operation{ + OperationID: "admin-users-patch-admin", + Summary: "Promote or demote a user (admin)", + Description: "Sets a user's instance-admin flag. The body field is a pointer: omitting is_admin leaves the flag unchanged. Demoting the last remaining admin is refused with 400.", + Method: http.MethodPatch, + Path: "/admin/users/{id}/admin", + Tags: tags, + }, adminUsersPatchAdmin) + + Register(api, huma.Operation{ + OperationID: "admin-users-patch-status", + Summary: "Set a user's status (admin)", + Description: "Changes a user's account status without requiring them to log in. The body field is a pointer: omitting status leaves it unchanged. Moving the last remaining admin out of Active is refused with 400.", + Method: http.MethodPatch, + Path: "/admin/users/{id}/status", + Tags: tags, + }, adminUsersPatchStatus) + + Register(api, huma.Operation{ + OperationID: "admin-users-delete", + Summary: "Delete a user (admin)", + Description: "Deletes a user. With mode=now the user is removed immediately. With mode=scheduled (the default) the user is scheduled for deletion through the email-confirmation self-deletion flow. Deleting the last remaining admin is refused with 400.", + Method: http.MethodDelete, + Path: "/admin/users/{id}", + Tags: tags, + }, adminUsersDelete) +} + +func init() { AddRouteRegistrar(RegisterAdminUserRoutes) } + +func adminOverview(_ context.Context, _ *struct{}) (*adminOverviewBody, error) { + s := db.NewSession() + defer s.Close() + + overview, err := models.BuildOverview(s) + if err != nil { + return nil, translateDomainError(err) + } + return &adminOverviewBody{Body: overview}, nil +} + +func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody }) (*adminUserBody, error) { + s := db.NewSession() + defer s.Close() + + newUser, err := models.CreateUserAsAdmin(s, &in.Body) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers. + if err != nil { + return nil, translateDomainError(err) + } + return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request +} + +func adminUsersPatchAdmin(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Body adminIsAdminPatchBody +}) (*adminUserBody, error) { + if in.Body.IsAdmin == nil { + return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"}) + } + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserAdminFlag(s, in.ID, *in.Body.IsAdmin) + }) +} + +func adminUsersPatchStatus(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Body adminStatusPatchBody +}) (*adminUserBody, error) { + if in.Body.Status == nil { + return nil, translateDomainError(models.ErrInvalidData{Message: "status is required"}) + } + newStatus := *in.Body.Status + if newStatus < user.StatusActive || newStatus > user.StatusAccountLocked { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"}) + } + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserStatusAsAdmin(s, in.ID, newStatus) + }) +} + +func adminUsersDelete(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Mode string `query:"mode" doc:"'now' deletes immediately; 'scheduled' (the default) triggers the email-confirmation self-deletion flow."` +}) (*emptyBody, error) { + mode := in.Mode + if mode == "" { + mode = "scheduled" + } + if mode != "now" && mode != "scheduled" { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"}) + } + + s := db.NewSession() + defer s.Close() + if err := models.DeleteUserAsAdmin(s, in.ID, mode); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +// adminCommitUser runs a user-returning admin action in its own transaction and +// renders the admin user view. The action does the load/guard/mutate against the +// session (shared with v1 via the models layer); this owns the commit and response. +func adminCommitUser(action func(s *xorm.Session) (*user.User, error)) (*adminUserBody, error) { + s := db.NewSession() + defer s.Close() + + target, err := action(s) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers. + if err != nil { + return nil, translateDomainError(err) + } + return &adminUserBody{Body: shared.NewAdminUser(target, providers)}, nil +} diff --git a/pkg/routes/api/v2/auth_login.go b/pkg/routes/api/v2/auth_login.go new file mode 100644 index 000000000..519fcaef1 --- /dev/null +++ b/pkg/routes/api/v2/auth_login.go @@ -0,0 +1,132 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// authTokenBody wraps the issued user JWT. The token is inlined rather than +// embedding auth.Token because Huma derives schema names from the bare Go type +// name and a top-level auth.Token body would collide with user.Token (the +// caldav-token schema, also named "Token"). The refresh token is delivered out +// of band as an HttpOnly cookie, so it is intentionally absent from the schema. +type authTokenBody struct { + // Cache-Control: no-store keeps the access token out of any shared cache. + CacheControl string `header:"Cache-Control"` + Body struct { + Token string `json:"token" readOnly:"true" doc:"The short-lived JWT auth token. Send it as a bearer token on subsequent requests."` + } +} + +// logoutBody confirms a successful logout. +type logoutBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + OIDCLogoutURL string `json:"oidc_logout_url,omitempty" readOnly:"true" doc:"RP-Initiated Logout URL to redirect to for OpenID Connect sessions; empty otherwise."` + } +} + +func init() { AddRouteRegistrar(RegisterLoginRoutes) } + +// RegisterLoginRoutes wires the local/LDAP login and logout endpoints. Login is +// always registered (LDAP-only deployments still log in here); logout inherits +// the global JWT auth. +func RegisterLoginRoutes(api huma.API) { + tags := []string{"auth"} + + Register(api, huma.Operation{ + OperationID: "auth-login", + Summary: "Login", + Description: "Logs a user in with username and password (and a TOTP passcode when 2FA is enabled), returning a short-lived JWT. A long-lived refresh token is set as an HttpOnly cookie scoped to the refresh endpoint.", + Method: http.MethodPost, + Path: "/login", + DefaultStatus: http.StatusOK, + Tags: tags, + Security: publicSecurity, + }, authLogin) + + Register(api, huma.Operation{ + OperationID: "auth-logout", + Summary: "Logout", + Description: "Destroys the current session server-side and clears the refresh-token cookie. A no-op for API tokens and link shares, which carry no session.", + Method: http.MethodPost, + Path: "/logout", + DefaultStatus: http.StatusOK, + Tags: tags, + }, authLogout) +} + +func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBody, error) { + u, err := shared.AuthenticateUserCredentials(ctx, &in.Body) + if err != nil { + return nil, translateDomainError(err) + } + + deviceInfo, ipAddress := requestClientInfo(ctx) + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken, nil) + if err != nil { + return nil, translateDomainError(err) + } + + if ec := echoContextFromCtx(ctx); ec != nil { + auth.WriteUserAuthCookies(ec, token) + } + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = token.AccessToken + return out, nil +} + +func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) { + var sid string + if ec := echoContextFromCtx(ctx); ec != nil { + auth.ClearRefreshTokenCookie(ec) + sid = auth.SessionIDFromContext(ec) + } + + oidcLogoutURL, err := shared.LogoutSession(sid) //nolint:contextcheck // OIDC provider discovery resolves from a cached, context-less map and runs on its own background context, like the OIDC callback. + if err != nil { + return nil, translateDomainError(err) + } + + out := &logoutBody{} + out.Body.Message = "Successfully logged out." + out.Body.OIDCLogoutURL = oidcLogoutURL + return out, nil +} + +// echoContextFromCtx pulls the underlying *echo.Context off a Huma request +// context so a handler can set cookies and headers the OpenAPI schema does not +// model (the refresh-token cookie). Returns nil when the context carries no echo +// context (it always does under the humaecho5 adapter). +func echoContextFromCtx(ctx context.Context) *echo.Context { + ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok || ec == nil { + return nil + } + return ec +} diff --git a/pkg/routes/api/v2/auth_openid.go b/pkg/routes/api/v2/auth_openid.go new file mode 100644 index 000000000..5e029a184 --- /dev/null +++ b/pkg/routes/api/v2/auth_openid.go @@ -0,0 +1,93 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "errors" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/openid" + + "github.com/danielgtaylor/huma/v2" +) + +func init() { AddRouteRegistrar(RegisterOpenIDRoutes) } + +// RegisterOpenIDRoutes wires the OpenID Connect callback endpoint. It is only +// registered when OpenID is enabled; individual providers are still resolved per +// request, so an unknown provider key 404s even when others are configured. +func RegisterOpenIDRoutes(api huma.API) { + if !config.AuthOpenIDEnabled.GetBool() { + return + } + + Register(api, huma.Operation{ + OperationID: "auth-openid-callback", + Summary: "Authenticate with OpenID Connect", + Description: "Exchanges the authorization code returned by an OpenID Connect provider for a Vikunja JWT, creating or updating the matching user. A long-lived refresh token is set as an HttpOnly cookie. When the resolved user has 2FA enabled, the call returns 412 and must be retried with totp_passcode set.", + Method: http.MethodPost, + Path: "/auth/openid/{provider}/callback", + DefaultStatus: http.StatusOK, + Tags: []string{"auth"}, + Security: publicSecurity, + }, authOpenIDCallback) +} + +func authOpenIDCallback(ctx context.Context, in *struct { + Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."` + Body openid.Callback `doc:"The provider callback, carrying the authorization code."` +}) (*authTokenBody, error) { + u, oidcData, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback. + if err != nil { + return nil, translateOpenIDError(err) + } + + deviceInfo, ipAddress := requestClientInfo(ctx) + // OIDC logins are not "remember me" sessions; v1 always issues a short one. + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false, oidcData) + if err != nil { + return nil, translateDomainError(err) + } + + if ec := echoContextFromCtx(ctx); ec != nil { + auth.WriteUserAuthCookies(ec, token) + } + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = token.AccessToken + return out, nil +} + +// translateOpenIDError maps OIDC callback errors to RFC 9457 responses. +// ErrOpenIDBadRequestWithDetails carries no HTTP semantics of its own (v1 renders +// it with a bespoke {message, details} body), so v2 maps it to a 400 with the +// provider detail attached as a structured error detail rather than porting the +// bespoke shape. Everything else flows through translateDomainError. +func translateOpenIDError(err error) error { + var detailedErr *models.ErrOpenIDBadRequestWithDetails + if errors.As(err, &detailedErr) { + return huma.Error400BadRequest(detailedErr.Message, &huma.ErrorDetail{ + Message: "The identity provider rejected the request.", + Value: detailedErr.Details, + }) + } + return translateDomainError(err) +} diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go new file mode 100644 index 000000000..c41fb162d --- /dev/null +++ b/pkg/routes/api/v2/auth_public.go @@ -0,0 +1,183 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// publicSecurity is the empty security requirement that opts an operation out of +// the globally-applied JWT/API-token auth. The matching Echo path must also be +// listed in unauthenticatedAPIPaths so the token middleware lets it through. +var publicSecurity = []map[string][]string{} + +// registerUserBody is the response wrapper for the registration endpoint. +type registerUserBody struct { + Body *user.User +} + +// messageBody carries a human-readable confirmation for endpoints that report +// success without returning a resource (password reset, email confirm). +type messageBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + } +} + +// linkShareTokenBody wraps the issued link-share auth token and its share. +type linkShareTokenBody struct { + Body *shared.LinkShareToken +} + +func init() { AddRouteRegistrar(RegisterPublicAuthRoutes) } + +// RegisterPublicAuthRoutes wires the unauthenticated local-account flows +// (registration, password reset, email confirmation) and the link-share auth +// endpoint. The local-account flows mirror v1 by only registering when local +// auth is enabled; the link-share endpoint follows ServiceEnableLinkSharing. +func RegisterPublicAuthRoutes(api huma.API) { + if config.AuthLocalEnabled.GetBool() { + registerLocalAuthRoutes(api) + } + + if config.ServiceEnableLinkSharing.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-link-share", + Summary: "Get an auth token for a link share", + Description: "Exchanges a link share's public hash (and password, for password-protected shares) for a JWT auth token scoped to the shared project.", + Method: http.MethodPost, + Path: "/shares/{share}/auth", + DefaultStatus: http.StatusOK, + Tags: []string{"sharing"}, + Security: publicSecurity, + }, authLinkShare) + } +} + +func registerLocalAuthRoutes(api huma.API) { + authTags := []string{"auth"} + + // Registration is its own static-config gate on top of local auth: when it + // is disabled the route simply isn't registered (a request then 404s as an + // unknown route), rather than registering it and rejecting per request. + if config.ServiceEnableRegistration.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-register", + Summary: "Register", + Description: "Creates a new local user account.", + Method: http.MethodPost, + Path: "/register", + Tags: authTags, + Security: publicSecurity, + }, authRegister) + } + + Register(api, huma.Operation{ + OperationID: "auth-password-token", + Summary: "Request a password reset token", + Description: "Requests a token to reset the password for the account with the given email. The token is sent to that email; the response is the same whether or not an account exists.", + Method: http.MethodPost, + Path: "/user/password/token", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authRequestPasswordToken) + + Register(api, huma.Operation{ + OperationID: "auth-password-reset", + Summary: "Reset a password", + Description: "Sets a new password using a previously issued reset token. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password/reset", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authResetPassword) + + Register(api, huma.Operation{ + OperationID: "auth-confirm-email", + Summary: "Confirm an email address", + Description: "Confirms the email address of a newly registered user using the token sent to that email.", + Method: http.MethodPost, + Path: "/user/confirm", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authConfirmEmail) +} + +func authRegister(ctx context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { + newUser, err := shared.RegisterUser(ctx, &in.Body) + if err != nil { + return nil, translateDomainError(err) + } + return ®isterUserBody{Body: newUser}, nil +} + +func authRequestPasswordToken(_ context.Context, in *struct{ Body user.PasswordTokenRequest }) (*messageBody, error) { + if err := shared.RequestPasswordResetToken(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "Token was sent." + return out, nil +} + +func authResetPassword(_ context.Context, in *struct{ Body user.PasswordReset }) (*messageBody, error) { + if err := shared.ResetPassword(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The password was updated successfully." + return out, nil +} + +func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) (*messageBody, error) { + if err := shared.ConfirmEmail(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The email was confirmed successfully." + return out, nil +} + +func authLinkShare(_ context.Context, in *struct { + Share string `path:"share" doc:"The public hash of the link share."` + // Pointer so the body is optional: shares without a password are + // authenticated with no body at all. + Body *struct { + Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."` + } +}) (*linkShareTokenBody, error) { + var password string + if in.Body != nil { + password = in.Body.Password + } + + token, err := shared.AuthenticateLinkShare(in.Share, password) + if err != nil { + return nil, translateDomainError(err) + } + return &linkShareTokenBody{Body: token}, nil +} diff --git a/pkg/routes/api/v2/auth_refresh.go b/pkg/routes/api/v2/auth_refresh.go new file mode 100644 index 000000000..d264da7c1 --- /dev/null +++ b/pkg/routes/api/v2/auth_refresh.go @@ -0,0 +1,75 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/auth" + user2 "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +func init() { AddRouteRegistrar(RegisterRefreshTokenRoutes) } + +// RegisterRefreshTokenRoutes wires the refresh-token endpoint. It authenticates +// via the HttpOnly refresh cookie rather than a JWT, so it is a public operation. +func RegisterRefreshTokenRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "auth-refresh-token", + Summary: "Refresh user token", + Description: "Exchanges the refresh-token cookie for a new short-lived JWT. The refresh token is rotated on every call, so the previous one stops working. A new HttpOnly refresh cookie is set on the response.", + Method: http.MethodPost, + Path: "/user/token/refresh", + DefaultStatus: http.StatusOK, + Tags: []string{"auth"}, + Security: publicSecurity, + }, authRefreshToken) +} + +func authRefreshToken(ctx context.Context, _ *struct{}) (*authTokenBody, error) { + ec := echoContextFromCtx(ctx) + if ec == nil { + return nil, huma.Error401Unauthorized("No refresh token provided.") + } + + cookie, err := ec.Cookie(auth.RefreshTokenCookieName) + if err != nil || cookie.Value == "" { + return nil, huma.Error401Unauthorized("No refresh token provided.") + } + + result, err := auth.RefreshSession(cookie.Value) + if err != nil { + if user2.IsErrUserStatusError(err) { + auth.ClearRefreshTokenCookie(ec) + } + return nil, translateDomainError(err) + } + + cookieMaxAge := int(config.ServiceJWTTTL.GetInt64()) + if result.IsLongSession { + cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) + } + auth.SetRefreshTokenCookie(ec, result.NewRefreshToken, cookieMaxAge) + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = result.AccessToken + return out, nil +} diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go new file mode 100644 index 000000000..4d7b5befe --- /dev/null +++ b/pkg/routes/api/v2/backgrounds.go @@ -0,0 +1,407 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "io" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/background" + backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" + "code.vikunja.io/api/pkg/modules/background/unsplash" + "code.vikunja.io/api/pkg/modules/humaecho5" + webfiles "code.vikunja.io/api/pkg/web/files" + + "github.com/danielgtaylor/huma/v2" +) + +type backgroundSearchBody struct { + Body Paginated[*background.Image] +} + +// RegisterBackgroundRoutes wires the project-background actions onto the Huma +// API. BackgroundsEnabled / BackgroundsUnsplashEnabled are static config, so the +// registrar early-returns instead of gating per request. +func RegisterBackgroundRoutes(api huma.API) { + if !config.BackgroundsEnabled.GetBool() { + return + } + + tags := []string{"project"} + + Register(api, huma.Operation{ + OperationID: "projects-background-delete", + Summary: "Remove a project background", + Description: "Removes a project's background, whichever provider set it. Succeeds even when the project has no background. Requires write access to the project. Returns the updated project.", + Method: http.MethodDelete, + Path: "/projects/{project}/background", + // Return the updated project with 200, not the wrapper's DELETE default 204. + DefaultStatus: http.StatusOK, + Tags: tags, + }, backgroundRemove) + + Register(api, huma.Operation{ + OperationID: "projects-background-get", + Summary: "Get a project background", + Description: "Streams a project's background image, whichever provider set it. Requires read access to the project. Always served as image/jpeg with a revalidation Last-Modified header, so a conditional If-Modified-Since request gets a 304. Returns 404 when the project has no background.", + Method: http.MethodGet, + Path: "/projects/{project}/background", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The project background as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, backgroundGet) + + if config.BackgroundsUploadEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "projects-background-upload", + Summary: "Upload a project background", + Description: "Uploads an image via multipart/form-data under the \"background\" field and sets it as the project's background. Requires write access to the project. The image is resized server-side and stored as JPEG; it replaces any previous background (idempotent replace, hence PUT). Returns the updated project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/upload", + // Return the updated project with 200, the natural code for an idempotent PUT. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, backgroundUpload) + } + + if config.BackgroundsUnsplashEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-search", + Summary: "Search Unsplash backgrounds", + Description: "Searches Unsplash for background images. With an empty query it returns the featured wallpaper collection. Results are paginated by Unsplash; total counts are not available.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/search", + Tags: tags, + }, backgroundUnsplashSearch) + + Register(api, huma.Operation{ + OperationID: "projects-background-unsplash-set", + Summary: "Set an Unsplash image as project background", + Description: "Sets a previously searched Unsplash image as the project's background, identified by the image id from the search results. Requires write access to the project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/unsplash", + Tags: tags, + }, backgroundUnsplashSet) + + unsplashProxyResponses := map[string]*huma.Response{ + "200": { + Description: "The proxied Unsplash image as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + } + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-image", + Summary: "Proxy a full-resolution Unsplash image", + Description: "Proxies the full-resolution Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashImage) + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-thumb", + Summary: "Proxy an Unsplash image thumbnail", + Description: "Proxies a thumbnail (max width 200px) of the Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}/thumb", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashThumb) + } +} + +func init() { AddRouteRegistrar(RegisterBackgroundRoutes) } + +func backgroundUnsplashSearch(ctx context.Context, in *struct { + Q string `query:"q" doc:"Search query; empty returns the featured wallpaper collection."` + Page int64 `query:"page" default:"1" minimum:"1" doc:"1-based page number."` +}) (*backgroundSearchBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + page := in.Page + if page < 1 { + page = 1 + } + + s := db.NewSession() + defer s.Close() + + p := &unsplash.Provider{} + result, err := p.Search(s, in.Q, page) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + // Unsplash paginates server-side and p.Search discards the total, so the + // envelope's total is just this page's length (v1 returned a bare array). + return &backgroundSearchBody{Body: NewPaginated(result, int64(len(result)), int(page), len(result))}, nil +} + +func backgroundUnsplashSet(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + Body background.Image +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + p := &unsplash.Provider{} + if err := p.Set(s, &in.Body, project, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := project.ReadOne(s, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + +type backgroundUploadInput struct { + ProjectID int64 `path:"project" doc:"The id of the project to set the background on."` + // Allow-list mirrors the formats background uploads can actually be decoded as + // (handler.ValidateAndSaveBackgroundUpload's allowedImageMimes); octet-stream covers + // programmatic clients. Huma's MimeTypeValidator rejects the part pre-handler, so the + // byte-level image check in the shared function is the real gate. + RawBody huma.MultipartFormFiles[struct { + Background huma.FormFile `form:"background" contentType:"image/jpeg,image/png,image/gif,image/bmp,image/tiff,image/webp,application/octet-stream" required:"true" doc:"The background image to upload. Must be a decodable raster image (JPEG, PNG, GIF, BMP, TIFF or WebP); it is resized server-side and re-encoded as JPEG."` + }] +} + +// backgroundUpload owns auth, the session and the permission check because there is +// no handler.Do* for multipart uploads (see the api-v2-routes skill's "Non-CRUDable +// / custom routes" section). It shares its body with v1 via +// handler.ValidateAndSaveBackgroundUpload. +func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + file := in.RawBody.Data().Background + defer func() { _ = file.Close() }() + + if err := backgroundHandler.ValidateAndSaveBackgroundUpload(s, a, project, file, file.Filename, uint64(file.Size)); err != nil { + _ = s.Rollback() + if backgroundHandler.IsErrFileIsNoImage(err) || backgroundHandler.IsErrFileUnsupportedImageFormat(err) { + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + +// backgroundGet owns auth, the session and the permission check because there is no +// handler.Do* for a file body. CanRead hydrates the project (including its +// BackgroundFileID), which the shared loader then needs. +func backgroundGet(ctx context.Context, in *struct { + ProjectID int64 `path:"project" doc:"The id of the project whose background to fetch."` +}) (*huma.StreamResponse, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, _, err := project.CanRead(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + bgFile, stat, err := backgroundHandler.LoadProjectBackgroundForDownload(s, project) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + // The stream callback (which closes the reader) won't run on this error path. + _ = bgFile.File.Close() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = bgFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteProjectBackground((*c).Response(), (*c).Request(), bgFile, stat) + }}, nil +} + +func backgroundUnsplashImage(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashImageByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +func backgroundUnsplashThumb(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashThumbByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +// streamUnsplashProxy copies the open upstream Unsplash body to the response as +// image/jpeg and closes it, mirroring v1's c.Stream. +func streamUnsplashProxy(body io.ReadCloser) *huma.StreamResponse { + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = body.Close() }() + c := humaecho5.Unwrap(hctx) + resp := (*c).Response() + resp.Header().Set("Content-Type", "image/jpg") + resp.WriteHeader(http.StatusOK) + _, _ = io.Copy(resp, body) + }} +} + +func backgroundRemove(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + if err := project.DeleteBackgroundFileIfExists(s); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.ClearProjectBackground(s, project.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} diff --git a/pkg/routes/api/v2/bulk_task.go b/pkg/routes/api/v2/bulk_task.go new file mode 100644 index 000000000..be5e4b31e --- /dev/null +++ b/pkg/routes/api/v2/bulk_task.go @@ -0,0 +1,61 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterBulkTaskRoutes wires the bulk task update action onto the Huma API. +// +// BulkTask is a CRUDable Update, so the handler reuses handler.DoUpdate; its +// CanUpdate fans the write check out across every project the involved tasks +// belong to, so a single project the user can't write to rejects the request. +func RegisterBulkTaskRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-bulk-update", + Summary: "Bulk update tasks", + Description: "Applies the fields named in `fields` from `values` to every task in `task_ids`. The user needs write access to every project the involved tasks belong to; if write is missing on even one, the whole request is rejected and nothing is changed. Returns the updated tasks.", + Method: http.MethodPut, + Path: "/tasks/bulk", + Tags: tags, + }, tasksBulkUpdate) +} + +func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) } + +func tasksBulkUpdate(ctx context.Context, in *struct { + Body models.BulkTask +}) (*singleBody[models.BulkTask], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + bt := &in.Body + if err := handler.DoUpdate(ctx, bt, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.BulkTask]{Body: bt}, nil +} diff --git a/pkg/routes/api/v2/caldav_tokens.go b/pkg/routes/api/v2/caldav_tokens.go new file mode 100644 index 000000000..b8cfbc19c --- /dev/null +++ b/pkg/routes/api/v2/caldav_tokens.go @@ -0,0 +1,121 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// CalDAV tokens are scoped to the authenticated user, not a CRUDable resource: +// there is no per-token Can* method, so these handlers own their own user lookup +// (user.GetFromAuth refuses link shares) and session/commit lives in the user package. + +type caldavTokenListBody struct { + Body Paginated[*user.Token] +} + +type caldavTokenBody struct { + Body *user.Token +} + +// RegisterCalDAVTokenRoutes wires the current user's CalDAV token operations onto the Huma API. +func RegisterCalDAVTokenRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-create", + Summary: "Generate a CalDAV token", + Description: "Generates a CalDAV token for the authenticated user. The clear-text token is returned only in this response and can never be retrieved again. Link shares cannot have CalDAV tokens.", + Method: http.MethodPost, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensCreate) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-list", + Summary: "List CalDAV tokens", + Description: "Returns the authenticated user's CalDAV tokens. Only the id and creation date are returned — never the token value, which is shown once on creation.", + Method: http.MethodGet, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensList) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-delete", + Summary: "Delete a CalDAV token", + Description: "Deletes one of the authenticated user's CalDAV tokens by id. Tokens of other users are out of scope and cannot be deleted.", + Method: http.MethodDelete, + Path: "/user/settings/token/caldav/{id}", + Tags: tags, + }, caldavTokensDelete) +} + +func init() { AddRouteRegistrar(RegisterCalDAVTokenRoutes) } + +func caldavTokensCreate(ctx context.Context, _ *struct{}) (*caldavTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + token, err := user.GenerateNewCaldavToken(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenBody{Body: token}, nil +} + +func caldavTokensList(ctx context.Context, in *ListParams) (*caldavTokenListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + tokens, err := user.GetCaldavTokens(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenListBody{Body: NewPaginated(tokens, int64(len(tokens)), in.Page, in.PerPage)}, nil +} + +func caldavTokensDelete(ctx context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric id of the CalDAV token to delete."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + if err := user.DeleteCaldavTokenByID(u, in.ID); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go index 3292b2e2b..24b73a19d 100644 --- a/pkg/routes/api/v2/errors.go +++ b/pkg/routes/api/v2/errors.go @@ -28,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/web" "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" ) // authFromCtx retrieves the authed user from a Huma handler context, @@ -80,6 +81,17 @@ func translateDomainError(err error) error { } return se } + // Shared transport-agnostic cores (e.g. auth.RefreshSession) signal HTTP + // semantics with *echo.HTTPError. v1 lets echo's error handler render it; + // without this it would fall through as a 500 on v2. + var he *echo.HTTPError + if errors.As(err, &he) { + msg := he.Message + if msg == "" { + msg = http.StatusText(he.Code) + } + return huma.NewError(he.Code, msg) + } return err } diff --git a/pkg/routes/api/v2/health.go b/pkg/routes/api/v2/health.go new file mode 100644 index 000000000..674dc7b85 --- /dev/null +++ b/pkg/routes/api/v2/health.go @@ -0,0 +1,61 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/health" + + "github.com/danielgtaylor/huma/v2" +) + +type healthBody struct { + Body struct { + Status string `json:"status" doc:"\"OK\" when the service and its dependencies are reachable." example:"OK"` + } +} + +// RegisterHealthRoutes wires the public healthcheck endpoint onto the Huma API. +func RegisterHealthRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "health", + Summary: "Healthcheck", + Description: "Reports whether the service and its dependencies (database) are reachable. Returns 200 with status \"OK\" when healthy, 500 otherwise. Public — no authentication required.", + Method: http.MethodGet, + Path: "/health", + Tags: []string{"service"}, + // Public: opt out of the globally-applied auth. The path is also listed + // in unauthenticatedAPIPaths so the token middleware lets it through. + Security: []map[string][]string{}, + }, healthcheck) +} + +func init() { AddRouteRegistrar(RegisterHealthRoutes) } + +func healthcheck(_ context.Context, _ *struct{}) (*healthBody, error) { + //nolint:contextcheck // health.Check is the shared v1/v2 probe; it takes no context and uses background contexts for its own pings. + if err := health.Check(); err != nil { + // Mirror v1: a failed check is an internal error; the cause is logged, + // not leaked to the client. + return nil, huma.Error500InternalServerError("Internal server error", err) + } + out := &healthBody{} + out.Body.Status = "OK" + return out, nil +} diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 7ad6f18f6..9c674c1a7 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -19,7 +19,10 @@ package apiv2 import ( "context" + "encoding/json" + "io" "net/http" + "net/url" "strings" "code.vikunja.io/api/pkg/config" @@ -31,6 +34,36 @@ import ( "github.com/labstack/echo/v5" ) +// formURLEncodedContentType is the content type the OAuth token endpoint accepts +// in addition to JSON, per RFC 6749. +const formURLEncodedContentType = "application/x-www-form-urlencoded" + +// formURLEncodedFormat lets Huma bind application/x-www-form-urlencoded request +// bodies into the same json-tagged structs it uses for JSON: the form values are +// re-marshaled to JSON and decoded via the standard path. Only string scalars +// are produced, which is all the form-encoded endpoints (OAuth token) need. +var formURLEncodedFormat = huma.Format{ + Marshal: func(io.Writer, any) error { + // Responses are always JSON; this format is request-body only. + return huma.ErrUnknownContentType + }, + Unmarshal: func(data []byte, v any) error { + values, err := url.ParseQuery(string(data)) + if err != nil { + return err + } + flat := make(map[string]string, len(values)) + for key := range values { + flat[key] = values.Get(key) + } + raw, err := json.Marshal(flat) + if err != nil { + return err + } + return json.Unmarshal(raw, v) + }, +} + // GroupPrefix is the URL prefix the Echo group for /api/v2 is mounted at. const GroupPrefix = "/api/v2" @@ -44,6 +77,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { // Real presence/format rules live in `valid:` tags, enforced by govalidator in // the Register wrapper; leave the schema permissive so partial updates match v1. cfg.FieldsOptionalByDefault = true + // Accept application/x-www-form-urlencoded bodies (the OAuth token endpoint) + // alongside JSON. Copy the default map so we don't mutate the package global. + formats := make(map[string]huma.Format, len(cfg.Formats)+1) + for ct, f := range cfg.Formats { + formats[ct] = f + } + formats[formURLEncodedContentType] = formURLEncodedFormat + cfg.Formats = formats api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg) oapi := api.OpenAPI() @@ -63,6 +104,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { Scheme: "bearer", Description: "Vikunja API token (tk_ prefix) with scoped permissions. Created via /api/v1/tokens.", } + // HTTP Basic, used only by the notifications Atom feed: feed readers can't + // carry a bearer header, so the feed accepts the API token as the Basic + // password (username = token owner). See notifications_feed.go. + oapi.Components.SecuritySchemes["BasicAuth"] = &huma.SecurityScheme{ + Type: "http", + Scheme: "basic", + Description: "HTTP Basic auth used by the notifications Atom feed: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix).", + } // Applied globally; public endpoints (spec, docs) opt out with an empty Security list. oapi.Security = []map[string][]string{ {"JWTKeyAuth": {}}, diff --git a/pkg/routes/api/v2/info.go b/pkg/routes/api/v2/info.go new file mode 100644 index 000000000..3b4256363 --- /dev/null +++ b/pkg/routes/api/v2/info.go @@ -0,0 +1,51 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/routes/api/shared" + + "github.com/danielgtaylor/huma/v2" +) + +type infoBody struct { + Body shared.VikunjaInfos +} + +// RegisterInfoRoutes wires the public instance-info endpoint onto the Huma API. +func RegisterInfoRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "info", + Summary: "Instance info", + Description: "Returns version, frontend URL, motd and the enabled features of this Vikunja instance. Public — no authentication required.", + Method: http.MethodGet, + Path: "/info", + Tags: []string{"service"}, + // Public: opt out of the globally-applied auth. The path is also listed + // in unauthenticatedAPIPaths so the token middleware lets it through. + Security: []map[string][]string{}, + }, info) +} + +func init() { AddRouteRegistrar(RegisterInfoRoutes) } + +func info(_ context.Context, _ *struct{}) (*infoBody, error) { + return &infoBody{Body: shared.BuildInfo()}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request +} diff --git a/pkg/routes/api/v2/label_task_bulk.go b/pkg/routes/api/v2/label_task_bulk.go new file mode 100644 index 000000000..82f837540 --- /dev/null +++ b/pkg/routes/api/v2/label_task_bulk.go @@ -0,0 +1,62 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the +// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose +// CanCreate enforces write access to the task), but the verb is PUT because the +// operation replaces the task's whole label set — the idempotent PUT semantics +// describe it more honestly than POST. +func RegisterLabelTaskBulkRoutes(api huma.API) { + tags := []string{"labels"} + + Register(api, huma.Operation{ + OperationID: "task-labels-bulk-replace", + Summary: "Replace all labels on a task", + Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/labels/bulk", + Tags: tags, + }, labelTasksBulkReplace) +} + +func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) } + +func labelTasksBulkReplace(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."` + Body models.LabelTaskBulk +}) (*singleBody[models.LabelTaskBulk], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + in.Body.TaskID = in.TaskID // parent from the path, not the body + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil +} diff --git a/pkg/routes/api/v2/migration_csv.go b/pkg/routes/api/v2/migration_csv.go new file mode 100644 index 000000000..9f1922671 --- /dev/null +++ b/pkg/routes/api/v2/migration_csv.go @@ -0,0 +1,200 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/modules/migration/csv" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// csvDetectInput is the detect upload: just the file. +type csvDetectInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to analyze."` + }] +} + +// csvImportInput is the preview/migrate upload: the file plus a JSON config +// blob carried as a multipart form value (mirrors v1's FormValue(\"config\")). +type csvImportInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to import."` + Config string `form:"config" required:"true" doc:"The import configuration as a JSON object (see the ImportConfig schema), passed as a multipart form value. Obtain a starting config from the detect endpoint."` + }] +} + +type csvDetectBody struct { + Body *csv.DetectionResult +} + +type csvPreviewBody struct { + Body *csv.PreviewResult +} + +// RegisterMigrationCSVRoutes wires the generic CSV importer onto the Huma API. +// Like the other file migrators it has no config flag in v1, so it is always +// registered. +func RegisterMigrationCSVRoutes(api huma.API) { + tags := []string{"migration"} + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + maxBody := (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024 + + Register(api, huma.Operation{ + OperationID: "migration-csv-status", + Summary: "Get the CSV migration status", + Description: "Returns the migration status of the authenticated user for the CSV importer, i.e. whether and when they last imported a CSV.", + Method: http.MethodGet, + Path: "/migration/csv/status", + Tags: tags, + }, csvStatus) + + Register(api, huma.Operation{ + OperationID: "migration-csv-detect", + Summary: "Detect a CSV file's structure", + Description: "Analyzes an uploaded CSV file and returns its detected columns, delimiter, quote character and date format, plus a suggested column-to-attribute mapping the client can edit before previewing or migrating. Read-only: nothing is imported.", + Method: http.MethodPost, + Path: "/migration/csv/detect", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvDetect) + + Register(api, huma.Operation{ + OperationID: "migration-csv-preview", + Summary: "Preview a CSV import", + Description: "Returns the first few tasks that would be imported from the uploaded CSV file with the given config, without importing anything. Read-only.", + Method: http.MethodPost, + Path: "/migration/csv/preview", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvPreview) + + Register(api, huma.Operation{ + OperationID: "migration-csv-migrate", + Summary: "Import a CSV file", + Description: "Imports the tasks from the uploaded CSV file into Vikunja using the given config. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/csv/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvMigrate) +} + +func init() { AddRouteRegistrar(RegisterMigrationCSVRoutes) } + +func csvStatus(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(&csv.Migrator{}, u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func csvDetect(ctx context.Context, in *csvDetectInput) (*csvDetectBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.DetectCSVStructure(src, src.Size) + if err != nil { + return nil, translateDomainError(err) + } + return &csvDetectBody{Body: result}, nil +} + +func csvPreview(ctx context.Context, in *csvImportInput) (*csvPreviewBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.PreviewImport(src, src.Size, cfg) + if err != nil { + return nil, translateDomainError(err) + } + return &csvPreviewBody{Body: result}, nil +} + +func csvMigrate(ctx context.Context, in *csvImportInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := csv.RunMigration(u, src, src.Size, cfg); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} + +// parseCSVImportConfig unmarshals the JSON config form value, mirroring v1's +// json.Unmarshal of FormValue("config"). required:"true" guarantees presence, +// so only a malformed body needs guarding here. +func parseCSVImportConfig(raw string) (*csv.ImportConfig, error) { + var cfg csv.ImportConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return nil, huma.Error400BadRequest("Invalid configuration: " + err.Error()) + } + return &cfg, nil +} diff --git a/pkg/routes/api/v2/migration_file.go b/pkg/routes/api/v2/migration_file.go new file mode 100644 index 000000000..d02db596e --- /dev/null +++ b/pkg/routes/api/v2/migration_file.go @@ -0,0 +1,126 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/modules/migration/ticktick" + vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" + "code.vikunja.io/api/pkg/modules/migration/wekan" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// fileMigrateInput is the multipart upload body shared by every file migrator's +// migrate endpoint. +type fileMigrateInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The export file to import. Its expected format depends on the migrator (e.g. a Vikunja export zip, a TickTick CSV, a WeKan JSON export)."` + }] +} + +// RegisterMigrationFileRoutes wires the file-based migrators (Vikunja export, +// TickTick, WeKan) onto the Huma API. Unlike the OAuth migrators these have no +// config flag in v1, so they are always registered. +func RegisterMigrationFileRoutes(api huma.API) { + registerFileMigrator(api, func() migration.FileMigrator { return &vikunja_file.FileMigrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &ticktick.Migrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &wekan.Migrator{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationFileRoutes) } + +// registerFileMigrator registers status + migrate for a single file migrator. +// factory produces a fresh migrator instance per request, matching v1's +// MigrationStruct func so concurrent requests never share mutable state. +func registerFileMigrator(api huma.API, factory func() migration.FileMigrator) { + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationFileStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Imports the authenticated user's data from an uploaded export file into Vikunja. Send the file under the multipart \"import\" field. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, func(ctx context.Context, in *fileMigrateInput) (*migrationStartedBody, error) { + return migrationFileMigrate(ctx, factory, in) + }) +} + +func migrationFileStatus(ctx context.Context, factory func() migration.FileMigrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationFileMigrate(ctx context.Context, factory func() migration.FileMigrator, in *fileMigrateInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := migrationHandler.RunFileMigration(factory(), u, src, src.Size); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} diff --git a/pkg/routes/api/v2/migration_oauth.go b/pkg/routes/api/v2/migration_oauth.go new file mode 100644 index 000000000..4d254632c --- /dev/null +++ b/pkg/routes/api/v2/migration_oauth.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" + "code.vikunja.io/api/pkg/modules/migration/todoist" + "code.vikunja.io/api/pkg/modules/migration/trello" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// migrationAuthURLBody is the response for the OAuth auth-url endpoint. +type migrationAuthURLBody struct { + Body migrationHandler.AuthURL +} + +// migrationStatusBody is the response for the migration status endpoint. +type migrationStatusBody struct { + Body *migration.Status +} + +// migrationMigrateBody carries the OAuth code obtained from the auth url back +// to the server. It is applied onto the concrete migrator (whose field carries +// json:"code") so it works across migrators regardless of their field name. +type migrationMigrateBody struct { + Code string `json:"code" doc:"The OAuth code obtained after authorizing against the auth url."` +} + +// migrationStartedBody confirms the migration was kicked off; the actual work +// runs asynchronously. +type migrationStartedBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterMigrationOAuthRoutes wires the OAuth-based migrators (Todoist, Trello, +// Microsoft To-Do) onto the Huma API. Each migrator is gated behind its static +// config flag and exposes the same three operations, so registration is driven +// by one generic helper instead of three copy-pasted blocks. +func RegisterMigrationOAuthRoutes(api huma.API) { + registerOAuthMigrator(api, config.MigrationTodoistEnable.GetBool(), func() migration.Migrator { return &todoist.Migration{} }) + registerOAuthMigrator(api, config.MigrationTrelloEnable.GetBool(), func() migration.Migrator { return &trello.Migration{} }) + registerOAuthMigrator(api, config.MigrationMicrosoftTodoEnable.GetBool(), func() migration.Migrator { return µsofttodo.Migration{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationOAuthRoutes) } + +// registerOAuthMigrator registers auth/status/migrate for a single OAuth +// migrator. enabled gates the whole migrator (config early-return, no +// middleware); factory produces a fresh migrator instance per request, matching +// v1's MigrationStruct func so concurrent requests never share mutable state. +func registerOAuthMigrator(api huma.API, enabled bool, factory func() migration.Migrator) { + if !enabled { + return + } + + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-auth", + Summary: "Get the auth url for " + name, + Description: "Returns the OAuth url the user needs to authenticate against. The code obtained there is passed back to the migrate endpoint.", + Method: http.MethodGet, + Path: "/migration/" + name + "/auth", + Tags: tags, + }, func(_ context.Context, _ *struct{}) (*migrationAuthURLBody, error) { + return &migrationAuthURLBody{Body: migrationHandler.AuthURL{URL: factory().AuthURL()}}, nil + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated. Used to prevent starting a second migration while one is running.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationOAuthStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Starts a migration of the authenticated user's data from this service into Vikunja. The migration runs asynchronously; this returns once it has been queued. Refuses with 412 if a migration for this service is already running.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST kicks off a job rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, func(ctx context.Context, in *struct{ Body migrationMigrateBody }) (*migrationStartedBody, error) { + return migrationOAuthMigrate(ctx, factory, in.Body) + }) +} + +func migrationOAuthStatus(ctx context.Context, factory func() migration.Migrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationOAuthMigrate(ctx context.Context, factory func() migration.Migrator, body migrationMigrateBody) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + ms := factory() + // Apply the request payload onto the concrete migrator the same way v1's + // c.Bind does, so migrator-specific field names (e.g. Trello's Token, + // json:"code") bind transparently. + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, ms); err != nil { + return nil, huma.Error400BadRequest("invalid migration payload", err) + } + + if err := migrationHandler.StartMigration(ms, u); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Migration was started successfully." + return out, nil +} diff --git a/pkg/routes/api/v2/notifications_feed.go b/pkg/routes/api/v2/notifications_feed.go new file mode 100644 index 000000000..d6195def2 --- /dev/null +++ b/pkg/routes/api/v2/notifications_feed.go @@ -0,0 +1,103 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/routes/feeds" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// RegisterNotificationsFeedRoutes wires the Atom notifications feed onto the +// Huma API. It documents HTTP Basic auth (a feeds-scoped API token) because +// feed readers can't carry a bearer header. +func RegisterNotificationsFeedRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "notifications-atom-feed", + Summary: "Notifications Atom feed", + Description: "Returns the authenticated user's latest notifications as an Atom feed. Authenticated with HTTP Basic auth: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix) — password and LDAP credentials are rejected because feed URLs are commonly shared or cached. Fetching the feed does not mark notifications as read.", + Method: http.MethodGet, + Path: "/notifications.atom", + Tags: []string{"service"}, + // This op carries its own HTTP Basic auth instead of the global bearer + // schemes; the path is in unauthenticatedAPIPaths so the JWT middleware + // lets it through and the handler authenticates itself. + Security: []map[string][]string{{"BasicAuth": {}}}, + Responses: map[string]*huma.Response{ + "200": { + Description: "The notifications Atom feed.", + Content: map[string]*huma.MediaType{ + "application/atom+xml": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, notificationsAtomFeed) +} + +func init() { AddRouteRegistrar(RegisterNotificationsFeedRoutes) } + +// notificationsAtomFeed authenticates with HTTP Basic (sharing the feeds +// validator) and streams the Atom feed; there is no handler.Do* for a non-JSON +// body and the auth can't ride the group's JWT middleware. +func notificationsAtomFeed(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + c, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok { + return nil, huma.Error500InternalServerError("could not resolve request context") + } + + username, password, ok := (*c).Request().BasicAuth() + if !ok { + return nil, basicAuthChallenge(c) + } + + s := db.NewSession() + defer s.Close() + + u, err := feeds.AuthenticateFeedToken(s, username, password) + if err != nil { + return nil, translateDomainError(err) + } + if u == nil { + return nil, basicAuthChallenge(c) + } + + atom, err := feeds.BuildNotificationsAtomFeed(s, u) + if err != nil { + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + ec := humaecho5.Unwrap(hctx) + (*ec).Response().Header().Set(echo.HeaderContentType, feeds.AtomContentType) + _, _ = (*ec).Response().Write([]byte(atom)) + }}, nil +} + +// basicAuthChallenge returns a 401 carrying a WWW-Authenticate Basic challenge, +// mirroring v1's BasicAuth middleware so feed readers prompt for credentials. +func basicAuthChallenge(c *echo.Context) error { + (*c).Response().Header().Set(echo.HeaderWWWAuthenticate, `Basic realm="Restricted"`) + return huma.Error401Unauthorized(http.StatusText(http.StatusUnauthorized)) +} diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go new file mode 100644 index 000000000..a67441ad3 --- /dev/null +++ b/pkg/routes/api/v2/oauth.go @@ -0,0 +1,111 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// oauthTokenBody wraps the OAuth 2.0 token response. +type oauthTokenBody struct { + // Cache-Control: no-store is required by RFC 6749 §5.1 so tokens are not + // cached. v2 already sets it globally, but declaring it keeps the contract + // explicit in the spec. + CacheControl string `header:"Cache-Control"` + Body *oauth2server.TokenResponse +} + +// oauthAuthorizeBody wraps the OAuth 2.0 authorization response. +type oauthAuthorizeBody struct { + Body *oauth2server.AuthorizeResponse +} + +func init() { AddRouteRegistrar(RegisterOAuthRoutes) } + +// RegisterOAuthRoutes wires the OAuth 2.0 token and authorize endpoints. The +// token endpoint is public (it authenticates the request itself); authorize +// inherits the global JWT auth. +func RegisterOAuthRoutes(api huma.API) { + tags := []string{"auth"} + + Register(api, huma.Operation{ + OperationID: "oauth-token", + Summary: "OAuth 2.0 token endpoint", + Description: "Exchanges an authorization code (grant_type=authorization_code) or a refresh token (grant_type=refresh_token) for an access token. Accepts application/x-www-form-urlencoded per RFC 6749 as well as JSON.", + Method: http.MethodPost, + Path: "/oauth/token", + DefaultStatus: http.StatusOK, + Tags: tags, + Security: publicSecurity, + }, oauthToken) + + Register(api, huma.Operation{ + OperationID: "oauth-authorize", + Summary: "OAuth 2.0 authorize endpoint", + Description: "Creates a single-use authorization code for the authenticated user. PKCE (code_challenge with method S256) and a loopback or vikunja- scheme redirect_uri are required.", + Method: http.MethodPost, + Path: "/oauth/authorize", + DefaultStatus: http.StatusOK, + Tags: tags, + }, oauthAuthorize) +} + +func oauthToken(ctx context.Context, in *struct { + Body oauth2server.TokenRequest `contentType:"application/x-www-form-urlencoded"` +}) (*oauthTokenBody, error) { + deviceInfo, ipAddress := requestClientInfo(ctx) + resp, err := oauth2server.ExchangeToken(ctx, &in.Body, deviceInfo, ipAddress) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthTokenBody{CacheControl: "no-store", Body: resp}, nil +} + +func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.AuthorizeRequest }) (*oauthAuthorizeBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + resp, err := oauth2server.Authorize(&in.Body, u.ID) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthAuthorizeBody{Body: resp}, nil +} + +// requestClientInfo pulls the user agent and client IP off the underlying Echo +// request so the authorization_code grant (and login) can record them on the +// session they create, mirroring v1. Both fall back to "" when the context is +// unavailable. +func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) { + ec := echoContextFromCtx(ctx) + if ec == nil { + return "", "" + } + return (*ec).Request().UserAgent(), (*ec).RealIP() +} diff --git a/pkg/routes/api/v2/project_duplicate.go b/pkg/routes/api/v2/project_duplicate.go new file mode 100644 index 000000000..6a050b2c2 --- /dev/null +++ b/pkg/routes/api/v2/project_duplicate.go @@ -0,0 +1,63 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterProjectDuplicateRoutes wires the project-duplicate action onto the Huma API. +// +// ProjectDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate +// (its CanCreate enforces access); the only custom part is taking ProjectID from +// the path rather than the request body. +func RegisterProjectDuplicateRoutes(api huma.API) { + tags := []string{"projects"} + + Register(api, huma.Operation{ + OperationID: "projects-duplicate", + Summary: "Duplicate a project", + Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds — into a new project owned by the authenticated user. User/team/link shares are only copied when duplicate_shares is set to true. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.", + Method: http.MethodPost, + Path: "/projects/{projectid}/duplicate", + Tags: tags, + }, projectsDuplicate) +} + +func init() { AddRouteRegistrar(RegisterProjectDuplicateRoutes) } + +func projectsDuplicate(ctx context.Context, in *struct { + ProjectID int64 `path:"projectid" doc:"The numeric id of the project to duplicate."` + Body models.ProjectDuplicate +}) (*singleBody[models.ProjectDuplicate], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + pd := &in.Body + pd.ProjectID = in.ProjectID + if err := handler.DoCreate(ctx, pd, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.ProjectDuplicate]{Body: pd}, nil +} diff --git a/pkg/routes/api/v2/reactions.go b/pkg/routes/api/v2/reactions.go new file mode 100644 index 000000000..722b2615a --- /dev/null +++ b/pkg/routes/api/v2/reactions.go @@ -0,0 +1,135 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// {entitykind} stays a string: the model derives the numeric EntityKind from +// it and rejects unknown kinds. The enum tag (repeated on the create/delete +// inputs) makes Huma reject anything else with a 422 before the handler runs; +// keep the values in sync with models.Reaction.setEntityKindFromString. +type reactionPathParams struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` +} + +// Reactions list as a map keyed by reaction value, not a slice, so it does not +// fit the Paginated envelope. +type reactionListBody struct { + Body models.ReactionMap +} + +func RegisterReactionRoutes(api huma.API) { + tags := []string{"reactions"} + + Register(api, huma.Operation{ + OperationID: "reactions-list", + Summary: "List reactions for an entity", + Description: "Returns every reaction on the entity, grouped as a map keyed by reaction value; each value maps to the users who reacted with it. Requires read access to the entity. Not paginated.", + Method: http.MethodGet, + Path: "/{entitykind}/{entityid}/reactions", + Tags: tags, + }, reactionsList) + + Register(api, huma.Operation{ + OperationID: "reactions-create", + Summary: "React to an entity", + Description: "Adds the authenticated user's reaction to the entity. Requires write access. No-op if the same reaction already exists.", + Method: http.MethodPost, + Path: "/{entitykind}/{entityid}/reactions", + Tags: tags, + }, reactionsCreate) + + Register(api, huma.Operation{ + OperationID: "reactions-delete", + Summary: "Remove a reaction from an entity", + Description: "Removes the authenticated user's own reaction from the entity. The reaction to remove is named in the body (there is no per-reaction id), so this is a POST with a body rather than a DELETE. Requires write access.", + Method: http.MethodPost, + Path: "/{entitykind}/{entityid}/reactions/delete", + Tags: tags, + DefaultStatus: http.StatusOK, + }, reactionsDelete) +} + +func init() { AddRouteRegistrar(RegisterReactionRoutes) } + +func reactionsList(ctx context.Context, in *reactionPathParams) (*reactionListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &models.Reaction{EntityID: in.EntityID, EntityKindString: in.EntityKind} + result, _, _, err := handler.DoReadAll(ctx, r, a, "", 1, -1) + if err != nil { + return nil, translateDomainError(err) + } + reactions, ok := result.(models.ReactionMap) + if !ok { + return nil, fmt.Errorf("reactions.ReadAll returned unexpected type %T (expected models.ReactionMap)", result) + } + if reactions == nil { + reactions = models.ReactionMap{} + } + return &reactionListBody{Body: reactions}, nil +} + +// Path params are flattened (not via the embedded reactionPathParams) because +// Huma fails to bind an embedded path-param struct when the input also has a Body. +func reactionsCreate(ctx context.Context, in *struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` + Body models.Reaction +}) (*singleBody[models.Reaction], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &in.Body + r.EntityID = in.EntityID + r.EntityKindString = in.EntityKind + if err := handler.DoCreate(ctx, r, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Reaction]{Body: r}, nil +} + +func reactionsDelete(ctx context.Context, in *struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` + Body models.Reaction +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &in.Body + r.EntityID = in.EntityID + r.EntityKindString = in.EntityKind + if err := handler.DoDelete(ctx, r, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/task_assignees_bulk.go b/pkg/routes/api/v2/task_assignees_bulk.go new file mode 100644 index 000000000..a60d84248 --- /dev/null +++ b/pkg/routes/api/v2/task_assignees_bulk.go @@ -0,0 +1,60 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskAssigneeBulkRoutes wires the bulk assignee replacement onto the +// Huma API. PUT is the honest verb — the operation replaces the task's whole +// assignee set idempotently — even though the model implements it as a Create. +func RegisterTaskAssigneeBulkRoutes(api huma.API) { + tags := []string{"assignees"} + + Register(api, huma.Operation{ + OperationID: "task-assignees-bulk", + Summary: "Replace all assignees of a task", + Description: "Replaces the task's full assignee set with the users in the body: users not in the list are unassigned, new ones are added. Pass an empty array to unassign everyone. Each assignee must have access to the task's project, and the caller needs write access to the task.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/assignees/bulk", + Tags: tags, + }, taskAssigneesBulk) +} + +func init() { AddRouteRegistrar(RegisterTaskAssigneeBulkRoutes) } + +func taskAssigneesBulk(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask"` + Body models.BulkAssignees +}) (*singleBody[models.BulkAssignees], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + in.Body.TaskID = in.TaskID // URL wins over body + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.BulkAssignees]{Body: &in.Body}, nil +} diff --git a/pkg/routes/api/v2/task_attachments.go b/pkg/routes/api/v2/task_attachments.go new file mode 100644 index 000000000..9faee2c0d --- /dev/null +++ b/pkg/routes/api/v2/task_attachments.go @@ -0,0 +1,215 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/humaecho5" + webfiles "code.vikunja.io/api/pkg/web/files" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// models.TaskAttachment.ReadAll returns []*models.TaskAttachment. +type taskAttachmentListBody struct { + Body Paginated[*models.TaskAttachment] +} + +type taskAttachmentUploadInput struct { + TaskID int64 `path:"task" doc:"The id of the task to attach the files to."` + // Accept any upload; the byte-level mime detection happens in files.CreateWithSession, + // so there is no part content-type allow-list to enforce here (unlike the avatar endpoint). + RawBody huma.MultipartFormFiles[struct { + Files []huma.FormFile `form:"files" required:"true" doc:"One or more files to upload as task attachments. Send multiple parts under the same \"files\" field to upload several at once."` + }] +} + +type taskAttachmentUploadBody struct { + Body *webfiles.AttachmentUploadResult +} + +// RegisterTaskAttachmentRoutes wires task-attachment list/upload/download/delete onto +// the Huma API. The whole resource is gated by the service.enabletaskattachments config +// flag; the check runs here (not at init()) because RegisterAll fires after config loads. +func RegisterTaskAttachmentRoutes(api huma.API) { + if !config.ServiceEnableTaskAttachments.GetBool() { + return + } + + tags := []string{"task"} + + Register(api, huma.Operation{ + OperationID: "task-attachments-list", + Summary: "List a task's attachments", + Description: "Returns the attachment metadata for one task, paginated. Requires read access to the task. The file bytes are not included; fetch them from the download endpoint.", + Method: http.MethodGet, + Path: "/tasks/{task}/attachments", + Tags: tags, + }, taskAttachmentsList) + + Register(api, huma.Operation{ + OperationID: "task-attachments-upload", + Summary: "Upload task attachments", + Description: "Uploads one or more files as attachments to a task via multipart/form-data under the \"files\" field. Requires write access to the task. Each file is processed independently: a file that fails (for example, exceeding the configured size limit) is reported in the errors list while the others still succeed, so the request returns 201 even on a partial upload. The max size per file is the server's configured file size limit.", + Method: http.MethodPost, + Path: "/tasks/{task}/attachments", + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, taskAttachmentsUpload) + + Register(api, huma.Operation{ + OperationID: "task-attachments-download", + Summary: "Download a task attachment", + Description: "Returns the raw bytes of one attachment. Requires read access to the task. Pass preview_size to get a downscaled PNG preview instead — only for image attachments; for non-images or an unknown size the original file is returned. The Content-Type header carries the file's real mime type.", + Method: http.MethodGet, + Path: "/tasks/{task}/attachments/{attachment}", + Tags: tags, + // Spell out the binary response; a bare []byte Body would otherwise be + // modeled as a base64 JSON string instead of binary file data. + Responses: map[string]*huma.Response{ + "200": { + Description: "The attachment file bytes. The Content-Type header carries the file's mime type.", + Content: map[string]*huma.MediaType{ + "application/octet-stream": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, taskAttachmentsDownload) + + Register(api, huma.Operation{ + OperationID: "task-attachments-delete", + Summary: "Delete a task attachment", + Description: "Deletes one attachment and its underlying file. Requires write access to the task. The attachment must belong to the task in the path.", + Method: http.MethodDelete, + Path: "/tasks/{task}/attachments/{attachment}", + Tags: tags, + }, taskAttachmentsDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskAttachmentRoutes) } + +func taskAttachmentsList(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task whose attachments to list."` + ListParams +}) (*taskAttachmentListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TaskAttachment{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.TaskAttachment) + if !ok { + return nil, fmt.Errorf("taskAttachments.ReadAll returned unexpected type %T (expected []*models.TaskAttachment)", result) + } + return &taskAttachmentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} + +// taskAttachmentsUpload owns auth, the session and the permission check because +// there is no handler.Do* for multipart uploads (see the api-v2-routes skill's +// "Non-CRUDable / custom routes" section). +func taskAttachmentsUpload(ctx context.Context, in *taskAttachmentUploadInput) (*taskAttachmentUploadBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + formFiles := in.RawBody.Data().Files + uploads := make([]*models.AttachmentToUpload, 0, len(formFiles)) + for _, file := range formFiles { + uploads = append(uploads, &models.AttachmentToUpload{Reader: file, Filename: file.Filename, Size: uint64(file.Size)}) + } + + success, failures, err := models.UploadTaskAttachments(s, a, in.TaskID, uploads) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &taskAttachmentUploadBody{Body: webfiles.BuildUploadResult(success, failures)}, nil +} + +// taskAttachmentsDownload owns auth, the session and the permission check; there is +// no handler.Do* for a file body. It loads the attachment, then streams the bytes +// from the StreamResponse callback (no buffering — attachments can be large). +func taskAttachmentsDownload(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."` + AttachmentID int64 `path:"attachment" doc:"The id of the attachment to download."` + PreviewSize string `query:"preview_size" enum:"sm,md,lg,xl" doc:"If set and the attachment is an image, return a downscaled PNG preview instead of the original: sm=100px, md=200px, lg=400px, xl=800px. Ignored for non-image attachments."` +}) (*huma.StreamResponse, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + previewSize := models.GetPreviewSizeFromString(in.PreviewSize) + ta, preview, err := models.LoadTaskAttachmentForDownload(s, a, in.TaskID, in.AttachmentID, previewSize) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + c := humaecho5.Unwrap(hctx) + webfiles.WriteAttachmentDownload((*c).Response(), (*c).Request(), ta, preview) + }}, nil +} + +func taskAttachmentsDelete(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."` + AttachmentID int64 `path:"attachment" doc:"The id of the attachment to delete."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.TaskAttachment{ID: in.AttachmentID, TaskID: in.TaskID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/task_bucket.go b/pkg/routes/api/v2/task_bucket.go new file mode 100644 index 000000000..b07774b2f --- /dev/null +++ b/pkg/routes/api/v2/task_bucket.go @@ -0,0 +1,69 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskBucketRoutes wires the kanban task-bucket move onto the Huma API. +// +// TaskBucket exposes only Update, so the handler reuses handler.DoUpdate (its +// CanUpdate enforces write access on the bucket's project). The bucket and view +// come from the path; only the task id is read from the body. +func RegisterTaskBucketRoutes(api huma.API) { + tags := []string{"projects"} + + Register(api, huma.Operation{ + OperationID: "task-bucket-update", + Summary: "Place a task in a kanban bucket", + Description: "Moves a task into the given bucket of a project's kanban view. Requires write access to the project. " + + "Idempotent: re-sending the same bucket is a no-op. Side effects: moving a task into the view's done bucket marks it done (and out of it un-marks it); a repeating task moved into the done bucket is reopened and routed back to the default bucket instead. " + + "Moving a task into a bucket that is already at its task limit is rejected with 412. A bucket that does not resolve under the project and view in the path is rejected with 404.", + Method: http.MethodPut, + Path: "/projects/{project}/views/{view}/buckets/{bucket}/tasks", + Tags: tags, + }, taskBucketUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskBucketRoutes) } + +func taskBucketUpdate(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + ViewID int64 `path:"view"` + BucketID int64 `path:"bucket"` + Body models.TaskBucket +}) (*singleBody[models.TaskBucket], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tb := &in.Body + tb.ProjectID = in.ProjectID // URL wins over body + tb.ProjectViewID = in.ViewID // URL wins over body + tb.BucketID = in.BucketID // URL wins over body + if err := handler.DoUpdate(ctx, tb, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskBucket]{Body: tb}, nil +} diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go new file mode 100644 index 000000000..1a379dbe6 --- /dev/null +++ b/pkg/routes/api/v2/task_collection.go @@ -0,0 +1,235 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +const taskListFilterDoc = "Filtering, sorting and search apply to every variant. See https://vikunja.io/docs/filters for the filter language." + +type taskListBody struct { + Body Paginated[*models.Task] +} + +// bucketsWithTasksBody is the buckets-with-tasks response. It is not paginated: +// the view's bucket configuration bounds how many tasks each bucket carries, so +// page/per_page don't apply and total is simply the number of buckets. +type bucketsWithTasksBody struct { + Body struct { + Items []*models.Bucket `json:"items"` + Total int64 `json:"total" doc:"The number of buckets returned."` + } +} + +// TaskListQueryParams is the shared filter/sort/search/expand query block for +// every task-list variant. It must stay EXPORTED: Huma promotes an anonymous +// embed's params only when the embed field is itself exported, and an embed +// field is exported iff its type name is (a lowercase type name silently drops +// all of its params from binding and the spec). +// +// The three input structs below embed it but keep their path params inline: +// Huma lists every path:"" field regardless of the route template, so a shared +// project/view field would leak onto a narrower route as a phantom path param. +// taskListViewInput is shared by both view-scoped endpoints. +type TaskListQueryParams struct { + ListParams + Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."` + FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."` + FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."` + SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` + OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` +} + +type taskListAllInput struct { + TaskListQueryParams +} + +type taskListProjectInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + TaskListQueryParams +} + +type taskListViewInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + ViewID int64 `path:"view" doc:"The numeric id of the project view."` + TaskListQueryParams +} + +// taskListFilters is the bound query carried into the shared collection builder. +// The three input structs convert into it so the collection logic lives once. +type taskListFilters struct { + Q string + Filter string + FilterTimezone string + FilterIncludeNulls bool + SortBy []string + OrderBy []string + Expand []string +} + +func (in taskListAllInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListProjectInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListViewInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +// collection turns the bound query into a TaskCollection. The search term +// arrives as `q` but reaches the model through DoReadAll's search argument, not +// the collection's Search field. forceFlat keeps a kanban view path returning +// flat tasks; the buckets endpoint leaves it false for the polymorphic shape. +func (f taskListFilters) collection(projectID, viewID int64, forceFlat bool) (*models.TaskCollection, error) { + expand, err := parseTaskExpand(f.Expand) + if err != nil { + return nil, translateDomainError(err) + } + tc := &models.TaskCollection{ + ProjectID: projectID, + ProjectViewID: viewID, + Filter: f.Filter, + FilterTimezone: f.FilterTimezone, + FilterIncludeNulls: f.FilterIncludeNulls, + SortBy: f.SortBy, + OrderBy: f.OrderBy, + Expand: expand, + } + if forceFlat { + tc.SetForceFlatTasks() + } + return tc, nil +} + +func RegisterTaskCollectionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-list", + Summary: "List tasks across all projects", + Description: "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/tasks", + Tags: tags, + }, tasksListAll) + + Register(api, huma.Operation{ + OperationID: "project-tasks-list", + Summary: "List tasks in a project", + Description: "Returns the tasks in a project, paginated and flat. Requires read access to the project. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/tasks", + Tags: tags, + }, projectTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-tasks-list", + Summary: "List tasks in a project view", + Description: "Returns the tasks in a project view, paginated and flat. The view's own filter, sort and search are applied on top of the query. Always returns flat tasks, even for a kanban view — use the buckets endpoint to get tasks grouped by bucket. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/tasks", + Tags: tags, + }, projectViewTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-buckets-tasks-list", + Summary: "List a kanban view's buckets with their tasks", + Description: "Returns the buckets of a project's kanban view, each populated with the tasks in it. Requires read access to the project. Not paginated: the number and size of buckets follow the view's bucket configuration, so page/per_page do not apply. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/buckets/tasks", + Tags: tags, + }, projectViewBucketsTasksList) +} + +func init() { AddRouteRegistrar(RegisterTaskCollectionRoutes) } + +func tasksListAll(ctx context.Context, in *taskListAllInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, 0, 0) +} + +func projectTasksList(ctx context.Context, in *taskListProjectInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, 0) +} + +func projectViewTasksList(ctx context.Context, in *taskListViewInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, in.ViewID) +} + +// readFlatTasks runs DoReadAll for a flat-task endpoint and unwraps the result. +// The model authorizes (project/view CanRead) inside ReadAll, so there's no +// Can* call here. +func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, projectID, viewID int64) (*taskListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tc, err := f.collection(projectID, viewID, true) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, page, perPage) + if err != nil { + return nil, translateDomainError(err) + } + tasks, ok := result.([]*models.Task) + if !ok { + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result) + } + return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil +} + +func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*bucketsWithTasksBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + f := in.filters() + tc, err := f.collection(in.ProjectID, in.ViewID, false) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + buckets, ok := result.([]*models.Bucket) + if !ok { + // ReadAll only yields []*Bucket from the kanban branch; a flat []*Task + // here means the view has no bucket configuration, so there are no + // buckets to return. That's a client error, not a 500. + if _, isTasks := result.([]*models.Task); isTasks { + return nil, huma.Error400BadRequest("this view has no buckets; use the tasks endpoint for non-kanban views") + } + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result) + } + out := &bucketsWithTasksBody{} + out.Body.Items = buckets + out.Body.Total = total + return out, nil +} diff --git a/pkg/routes/api/v2/task_position.go b/pkg/routes/api/v2/task_position.go new file mode 100644 index 000000000..13a7e3af8 --- /dev/null +++ b/pkg/routes/api/v2/task_position.go @@ -0,0 +1,63 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskPositionRoutes wires the task-position update onto the Huma API. +// +// Setting a position is a plain CRUDable Update, so the handler reuses +// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only +// custom part is taking TaskID from the path rather than the request body. +func RegisterTaskPositionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-position-update", + Summary: "Set a task's position in a view", + Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.", + Method: http.MethodPut, + Path: "/tasks/{task}/position", + Tags: tags, + }, tasksPositionUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) } + +func tasksPositionUpdate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."` + Body models.TaskPosition +}) (*singleBody[models.TaskPosition], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tp := &in.Body + tp.TaskID = in.TaskID // URL wins over body + if err := handler.DoUpdate(ctx, tp, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskPosition]{Body: tp}, nil +} diff --git a/pkg/routes/api/v2/task_relations.go b/pkg/routes/api/v2/task_relations.go new file mode 100644 index 000000000..eb9fa305f --- /dev/null +++ b/pkg/routes/api/v2/task_relations.go @@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskRelationRoutes wires task-relation create/delete onto the Huma API. +// +// Both operations reuse handler.DoCreate/DoDelete; CanCreate enforces write on +// the base task + read on the other task and rejects invalid kinds, CanDelete +// enforces write on the base task. The only custom part is mapping the path +// segments onto the model. +func RegisterTaskRelationRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-relations-create", + Summary: "Create a task relation", + Description: "Relates two tasks. The authenticated user needs write access to the base task (in the path) and at least read access to the other task; the two tasks need not share a project. The inverse relation is created automatically (e.g. a subtask relation also stores the parenttask relation on the other task). Subtask/parenttask chains that would form a cycle are rejected.", + Method: http.MethodPost, + Path: "/tasks/{task}/relations", + Tags: tags, + }, tasksRelationsCreate) + + Register(api, huma.Operation{ + OperationID: "tasks-relations-delete", + Summary: "Delete a task relation", + Description: "Removes the relation identified by the base task, relation kind and other task. The automatically created inverse relation is removed as well. The authenticated user needs write access to the base task.", + Method: http.MethodDelete, + Path: "/tasks/{task}/relations/{relationKind}/{otherTask}", + Tags: tags, + }, tasksRelationsDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskRelationRoutes) } + +func tasksRelationsCreate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the base task to relate from."` + Body models.TaskRelation +}) (*singleBody[models.TaskRelation], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + rel := &in.Body + rel.TaskID = in.TaskID // URL wins over body + if err := handler.DoCreate(ctx, rel, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskRelation]{Body: rel}, nil +} + +// The relationKind enum mirrors models.TaskRelation.RelationKind's tag (see the sync note there). +func tasksRelationsDelete(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the base task."` + RelationKind models.RelationKind `path:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of the relation to remove."` + OtherTaskID int64 `path:"otherTask" doc:"The numeric id of the other task in the relation."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + rel := &models.TaskRelation{ + TaskID: in.TaskID, + RelationKind: in.RelationKind, + OtherTaskID: in.OtherTaskID, + } + if err := handler.DoDelete(ctx, rel, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/task_unread_status.go b/pkg/routes/api/v2/task_unread_status.go new file mode 100644 index 000000000..f7a944092 --- /dev/null +++ b/pkg/routes/api/v2/task_unread_status.go @@ -0,0 +1,73 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// taskReadBody confirms the mark-read action: the underlying model carries no +// JSON-exposed fields, so it returns a status message rather than a resource. +type taskReadBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API. +// +// Marking a task read clears the caller's unread entry for it, which is what +// drives the per-task "unread" dot shown for mentions and other notifications. +// The model's Update deletes that entry, so the action is idempotent — PUT, not +// POST. It is also unconditional: there is no read entry to clear for a task the +// caller cannot see, so it succeeds as a no-op rather than refusing. +func RegisterTaskUnreadStatusRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-mark-read", + Summary: "Mark a task as read", + Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/read", + Tags: tags, + }, tasksMarkRead) +} + +func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) } + +func tasksMarkRead(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."` +}) (*taskReadBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + t := &models.TaskUnreadStatus{TaskID: in.TaskID} + if err := handler.DoUpdate(ctx, t, a); err != nil { + return nil, translateDomainError(err) + } + out := &taskReadBody{} + out.Body.Message = "success" + return out, nil +} diff --git a/pkg/routes/api/v2/tasks.go b/pkg/routes/api/v2/tasks.go new file mode 100644 index 000000000..49fa21910 --- /dev/null +++ b/pkg/routes/api/v2/tasks.go @@ -0,0 +1,225 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "strconv" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/conditional" +) + +// expandDoc lists the accepted expand values; shared between the by-id and +// by-index operations so the docs stay in sync. +const expandDoc = "Embed extra, more expensive data in each task. Repeatable. One of: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread. Expanding can return more tasks than the page limit (subtasks) and inflate the response." + +// parseTaskExpand turns the raw `expand` query values into validated +// TaskCollectionExpandable entries. Kept package-level for the TaskCollection +// list endpoint, which accepts the same option. An invalid value returns the +// model's own validation error, which translateDomainError maps to 422. +func parseTaskExpand(raw []string) ([]models.TaskCollectionExpandable, error) { + if len(raw) == 0 { + return nil, nil + } + expand := make([]models.TaskCollectionExpandable, 0, len(raw)) + for _, e := range raw { + v := models.TaskCollectionExpandable(e) + if err := v.Validate(); err != nil { + return nil, err + } + expand = append(expand, v) + } + return expand, nil +} + +// RegisterTaskRoutes wires Task CRUD onto the Huma API. The list lives on +// TaskCollection, not here. +func RegisterTaskRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-read", + Summary: "Get a task", + Description: "Returns a single task by its numeric id. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified. " + expandDoc, + Method: "GET", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksRead) + + Register(api, huma.Operation{ + OperationID: "tasks-read-by-index", + Summary: "Get a task by its project index", + Description: "Returns a single task addressed by its per-project index. The {project} segment accepts either a numeric project id or a textual project identifier (e.g. \"PROJ\"); a value made solely of digits is always treated as an id. " + expandDoc, + Method: "GET", + Path: "/projects/{project}/tasks/by-index/{index}", + Tags: tags, + }, tasksReadByIndex) + + Register(api, huma.Operation{ + OperationID: "tasks-create", + Summary: "Create a task", + Description: "Creates a task in the project from the URL. The authenticated user needs write access to that project and becomes the task's creator.", + Method: "POST", + Path: "/projects/{project}/tasks", + Tags: tags, + }, tasksCreate) + + Register(api, huma.Operation{ + OperationID: "tasks-update", + Summary: "Update a task", + Description: "Replaces all of a task's fields; requires write access. Setting project_id to a different project moves the task and also requires write access to the target project. Use PATCH for a partial update.", + Method: "PUT", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksUpdate) + + Register(api, huma.Operation{ + OperationID: "tasks-delete", + Summary: "Delete a task", + Description: "Deletes a task. Requires write access to its project.", + Method: "DELETE", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskRoutes) } + +type taskReadOneBody struct { + models.Task + MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this task (0=read, 1=read/write, 2=admin)."` +} + +func tasksRead(ctx context.Context, in *struct { + ID int64 `path:"projecttask" doc:"The numeric id of the task."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + conditional.Params +}) (*singleReadBody[taskReadOneBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + expand, err := parseTaskExpand(in.Expand) + if err != nil { + return nil, translateDomainError(err) + } + task := &models.Task{ID: in.ID, Expand: expand} + maxPermission, err := handler.DoReadOne(ctx, task, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) +} + +func tasksReadByIndex(ctx context.Context, in *struct { + Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."` + Index int64 `path:"index" doc:"The per-project task index."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + conditional.Params +}) (*singleReadBody[taskReadOneBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + expand, err := parseTaskExpand(in.Expand) + if err != nil { + return nil, translateDomainError(err) + } + projectID, err := resolveProjectIdentifier(in.Project) + if err != nil { + return nil, err + } + + // ID 0 + ProjectID + Index makes the model resolve the id from the + // (project, index) pair in both CanRead and ReadOne. + task := &models.Task{ProjectID: projectID, Index: in.Index, Expand: expand} + maxPermission, err := handler.DoReadOne(ctx, task, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) +} + +func tasksCreate(ctx context.Context, in *struct { + Project int64 `path:"project" doc:"The numeric id of the project to create the task in."` + Body models.Task +}) (*singleBody[models.Task], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + task := &in.Body + task.ProjectID = in.Project // URL wins over body + if err := handler.DoCreate(ctx, task, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Task]{Body: task}, nil +} + +// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. +func tasksUpdate(ctx context.Context, in *struct { + ID int64 `path:"projecttask"` + Body taskReadOneBody +}) (*singleBody[models.Task], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + task := &in.Body.Task + task.ID = in.ID // URL wins over body + if err := handler.DoUpdate(ctx, task, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Task]{Body: task}, nil +} + +func tasksDelete(ctx context.Context, in *struct { + ID int64 `path:"projecttask"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.Task{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +// resolveProjectIdentifier turns the {project} path segment into a numeric +// project id. A pure-digit value is always an id (mirroring v1's +// ResolveProjectIdentifier middleware); anything else is looked up as a +// case-insensitive identifier and 404s if unknown. +func resolveProjectIdentifier(raw string) (int64, error) { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + return id, nil + } + s := db.NewSession() + defer s.Close() + project, err := models.GetProjectSimpleByIdentifier(s, raw) + if err != nil { + return 0, translateDomainError(err) + } + return project.ID, nil +} diff --git a/pkg/routes/api/v2/testing.go b/pkg/routes/api/v2/testing.go new file mode 100644 index 000000000..2f753f3fe --- /dev/null +++ b/pkg/routes/api/v2/testing.go @@ -0,0 +1,129 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/routes/api/shared" + + "github.com/danielgtaylor/huma/v2" +) + +// testingReplaceInput is the request for resetting a single table. The +// Authorization header carries the configured testing token (not a JWT or API +// token); the endpoint is public and checks it in-handler like v1. +type testingReplaceInput struct { + Table string `path:"table" doc:"The table to reset."` + // String (not bool) so absent is distinguishable from an explicit "false": + // like v1, an absent truncate parameter means truncate. Huma does not + // support *bool params, and a bool with default:"true" silently ignores an + // explicit ?truncate=false, so the parameter is read as a raw string and + // interpreted in the handler exactly like v1 does. + Truncate string `query:"truncate" enum:"true,false" doc:"Empty the table (and its dependents) before inserting the rows. Defaults to true; pass false to restore on top of existing data."` + Authorization string `header:"Authorization" doc:"The configured testing token."` + Body []map[string]any `doc:"The rows to write into the table. Free-form objects matching the table's columns."` +} + +type testingReplaceBody struct { + Body []map[string]any `doc:"The table's contents after the reset."` +} + +type testingTruncateAllInput struct { + Authorization string `header:"Authorization" doc:"The configured testing token."` +} + +type testingTruncateAllBody struct { + Body struct { + Message string `json:"message" doc:"Always \"ok\" on success."` + } +} + +// RegisterTestingRoutes wires the e2e testing-support endpoints onto the Huma +// API. They are only mounted when the testing token is configured, matching v1. +func RegisterTestingRoutes(api huma.API) { + if config.ServiceTestingtoken.GetString() == "" { + return + } + + tags := []string{"testing"} + // Public: opt out of the globally-applied JWT/API-token auth — these + // authenticate with the testing token via the Authorization header + // instead. Their paths are also listed in unauthenticatedAPIPaths so the + // token middleware lets them through. + noAuth := []map[string][]string{} + + Register(api, huma.Operation{ + OperationID: "testing-truncate-all", + Summary: "Truncate all tables", + Description: "Removes all data from every Vikunja table. Used by e2e tests to ensure a clean state before each test. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.", + Method: http.MethodDelete, + Path: "/test/all", + Tags: tags, + Security: noAuth, + // v1 returns 200 with a body rather than the 204 a DELETE would default to. + DefaultStatus: http.StatusOK, + }, testingTruncateAll) + + Register(api, huma.Operation{ + OperationID: "testing-replace-table", + Summary: "Reset a table to a defined state", + Description: "Replaces the contents of the named table with the rows in the payload and returns the resulting contents. Used by e2e tests to seed fixtures. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.", + Method: http.MethodPut, + Path: "/test/{table}", + Tags: tags, + Security: noAuth, + // Mirror v1's 201 for a successful reset. + DefaultStatus: http.StatusCreated, + }, testingReplaceTable) +} + +func init() { AddRouteRegistrar(RegisterTestingRoutes) } + +func testingReplaceTable(_ context.Context, in *testingReplaceInput) (*testingReplaceBody, error) { + if in.Authorization != config.ServiceTestingtoken.GetString() { + return nil, huma.Error403Forbidden("forbidden") + } + + // Mirror v1: absent or "true" truncates; only an explicit "false" appends. + truncate := in.Truncate == "true" || in.Truncate == "" + data, err := shared.ReplaceTableContents(in.Table, in.Body, truncate) + if err != nil { + log.Errorf("Error replacing table data: %v", err) + return nil, huma.Error500InternalServerError("could not replace table data") + } + + return &testingReplaceBody{Body: data}, nil +} + +func testingTruncateAll(_ context.Context, in *testingTruncateAllInput) (*testingTruncateAllBody, error) { + if in.Authorization != config.ServiceTestingtoken.GetString() { + return nil, huma.Error403Forbidden("forbidden") + } + + if err := shared.TruncateAllTestingTables(); err != nil { + log.Errorf("Error truncating all tables: %v", err) + return nil, huma.Error500InternalServerError("could not truncate tables") + } + + out := &testingTruncateAllBody{} + out.Body.Message = "ok" + return out, nil +} diff --git a/pkg/routes/api/v2/time_entries.go b/pkg/routes/api/v2/time_entries.go new file mode 100644 index 000000000..a58ee8b92 --- /dev/null +++ b/pkg/routes/api/v2/time_entries.go @@ -0,0 +1,285 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/conditional" +) + +// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when the license +// feature is off. It's a middleware because license state can change while the instance is running. +func timeTrackingGate(api huma.API) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + _ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found") + return + } + next(ctx) + } +} + +func registerGated[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) { + op.Middlewares = append(op.Middlewares, timeTrackingGate(api)) + Register(api, op, handler) +} + +type timeEntryListBody struct { + Body Paginated[*models.TimeEntry] +} + +// RegisterTimeEntryRoutes wires the time-entry CRUD surface onto the Huma API. +func RegisterTimeEntryRoutes(api huma.API) { + tags := []string{"time-entries"} + + registerGated(api, huma.Operation{ + OperationID: "time-entries-list", + Summary: "List time entries", + Description: "Returns the time entries the authenticated user can see, paginated. Filterable by date range, project, task and user.", + Method: http.MethodGet, + Path: "/time-entries", + Tags: tags, + }, timeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-read", + Summary: "Get a time entry", + Description: "Returns a single time entry. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.", + Method: http.MethodGet, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesRead) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-create", + Summary: "Create a time entry", + Description: "Logs a manual time entry for the authenticated user. Exactly one of task_id / project_id must be set.", + Method: http.MethodPost, + Path: "/time-entries", + Tags: tags, + }, timeEntriesCreate) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-update", + Summary: "Update a time entry", + Description: "Updates a time entry. Only the author may update it. The entry can be moved between a task and a project — exactly one of task_id / project_id must be set, and you need read access to the new one. PUT replaces all editable fields; use PATCH for a partial update.", + Method: http.MethodPut, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesUpdate) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-delete", + Summary: "Delete a time entry", + Description: "Deletes a time entry. Only the author may delete it. If it is the running timer, deleting it removes that timer.", + Method: http.MethodDelete, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesDelete) + + registerGated(api, huma.Operation{ + OperationID: "task-time-entries-list", + Summary: "List a task's time entries", + Description: "Returns the time entries logged against the given task, across all users, paginated. Scoped to what you can read: an inaccessible or unknown task yields an empty list, not an error.", + Method: http.MethodGet, + Path: "/tasks/{task_id}/time-entries", + Tags: tags, + }, taskTimeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "project-time-entries-list", + Summary: "List a project's time entries", + Description: "Returns the time entries for the given project — both standalone project entries and entries on tasks currently in the project — paginated. Scoped to what you can read: an inaccessible or unknown project yields an empty list, not an error.", + Method: http.MethodGet, + Path: "/projects/{project_id}/time-entries", + Tags: tags, + }, projectTimeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-timer-stop", + Summary: "Stop the running timer", + Description: "Stops the authenticated user's running timer, setting its end time to the server's current time, and returns the stopped entry. Returns 404 when no timer is running. Starting a timer and editing entries go through the regular create/update endpoints.", + Method: http.MethodPost, + Path: "/time-entries/timer/stop", + // Override the wrapper's POST→201: this stops an existing entry, it creates nothing. + DefaultStatus: http.StatusOK, + Tags: tags, + }, timeEntriesTimerStop) +} + +func init() { AddRouteRegistrar(RegisterTimeEntryRoutes) } + +// timeEntriesTimerStop is a custom action scoped to the caller: it stops their +// own running timer, so it owns its session and needs no resource permission +// beyond authentication. +func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + entry, err := models.StopRunningTimer(s, a) + if err != nil { + _ = s.Rollback() + events.CleanupPending(s) + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + events.CleanupPending(s) + return nil, translateDomainError(err) + } + events.DispatchPending(ctx, s) + return &singleBody[models.TimeEntry]{Body: entry}, nil +} + +func timeEntriesList(ctx context.Context, in *struct { + ListParams + Filter string `query:"filter" doc:"Filter entries with the task filter syntax over user_id, task_id, project_id, start_time and end_time — e.g. \"project_id = 5 && start_time > now-7d\". Use end_time = null to match running timers."` + FilterTimezone string `query:"filter_timezone" doc:"IANA timezone name used to resolve relative dates (now, now-7d) in the filter, e.g. Europe/Berlin."` +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + m := &models.TimeEntry{ + Filter: in.Filter, + FilterTimezone: in.FilterTimezone, + } + result, _, total, err := handler.DoReadAll(ctx, m, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +type timeEntryReadBody struct { + models.TimeEntry + MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this time entry (0=read, 1=read/write, 2=admin)."` +} + +func timeEntriesRead(ctx context.Context, in *struct { + ID int64 `path:"id"` + conditional.Params +}) (*singleReadBody[timeEntryReadBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + entry := &models.TimeEntry{ID: in.ID} + maxPermission, err := handler.DoReadOne(ctx, entry, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &timeEntryReadBody{TimeEntry: *entry, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, entry.Updated, maxPermission) +} + +func timeEntriesCreate(ctx context.Context, in *struct { + Body models.TimeEntry +}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TimeEntry]{Body: &in.Body}, nil +} + +// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. +func timeEntriesUpdate(ctx context.Context, in *struct { + ID int64 `path:"id"` + Body timeEntryReadBody +}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + entry := &in.Body.TimeEntry + entry.ID = in.ID // URL wins over body + if err := handler.DoUpdate(ctx, entry, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TimeEntry]{Body: entry}, nil +} + +func timeEntriesDelete(ctx context.Context, in *struct { + ID int64 `path:"id"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.TimeEntry{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func taskTimeEntriesList(ctx context.Context, in *struct { + TaskID int64 `path:"task_id"` + ListParams +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +func projectTimeEntriesList(ctx context.Context, in *struct { + ProjectID int64 `path:"project_id"` + ListParams +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +// timeEntriesListResponse turns the any-typed DoReadAll result into the list +// envelope, hard-failing on a type mismatch (the generic-any silent-empty trap). +func timeEntriesListResponse(result any, total int64, page, perPage int) (*timeEntryListBody, error) { + items, ok := result.([]*models.TimeEntry) + if !ok { + return nil, fmt.Errorf("timeEntries.ReadAll returned unexpected type %T (expected []*models.TimeEntry)", result) + } + return &timeEntryListBody{Body: NewPaginated(items, total, page, perPage)}, nil +} diff --git a/pkg/routes/api/v2/token_meta.go b/pkg/routes/api/v2/token_meta.go new file mode 100644 index 000000000..120c3c81e --- /dev/null +++ b/pkg/routes/api/v2/token_meta.go @@ -0,0 +1,138 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/danielgtaylor/huma/v2" +) + +// tokenTestBody is the response for the token-check endpoints. +type tokenTestBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A static confirmation message."` + } +} + +// apiRoutesBody is the response for the token-routes endpoint: the available +// API routes grouped by permission, for building API-token scopes. +type apiRoutesBody struct { + Body map[string]models.APITokenRoute +} + +// renewTokenBody wraps a freshly issued link-share JWT. The token field is +// inlined rather than embedding auth.Token because Huma derives schema names +// from the bare Go type name, and a top-level auth.Token body would collide with +// user.Token (the caldav-token schema, also named "Token"). +type renewTokenBody struct { + Body struct { + Token string `json:"token" readOnly:"true" doc:"The renewed JWT auth token."` + } +} + +func init() { AddRouteRegistrar(RegisterTokenMetaRoutes) } + +// RegisterTokenMetaRoutes wires the token introspection helpers and the +// link-share token renewal endpoint. +func RegisterTokenMetaRoutes(api huma.API) { + tags := []string{"auth"} + + // v1 served GET as a 200 "ok" and POST as a 418 teapot easter egg; v2 makes + // both a plain 200 so a token check is an ordinary success. + Register(api, huma.Operation{ + OperationID: "token-test", + Summary: "Test a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodGet, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenTest) + + Register(api, huma.Operation{ + OperationID: "token-check", + Summary: "Check a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodPost, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenCheck) + + Register(api, huma.Operation{ + OperationID: "token-routes", + Summary: "List API token routes", + Description: "Returns every API route available to scope an API token against, grouped by resource and permission. Covers both /api/v1 and /api/v2 routes.", + Method: http.MethodGet, + Path: "/routes", + Tags: []string{"api"}, + }, tokenRoutes) + + Register(api, huma.Operation{ + OperationID: "token-renew", + Summary: "Renew a link-share token", + Description: "Issues a fresh JWT for the current link share. Only link-share tokens can be renewed here; user sessions must use the refresh-token flow.", + Method: http.MethodPost, + Path: "/user/token", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenRenew) +} + +func tokenTest(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenCheck(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenRoutes(_ context.Context, _ *struct{}) (*apiRoutesBody, error) { + return &apiRoutesBody{Body: models.GetAPITokenRoutes()}, nil +} + +func tokenRenew(ctx context.Context, _ *struct{}) (*renewTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + // Only link-share tokens are renewable here; a user JWT lands as *user.User + // and must use the refresh-token flow instead. + share, ok := a.(*models.LinkSharing) + if !ok { + return nil, huma.Error400BadRequest("User tokens cannot be renewed via this endpoint. Use the refresh-token flow instead.") + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + return nil, translateDomainError(err) + } + + out := &renewTokenBody{} + out.Body.Token = t + return out, nil +} diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go new file mode 100644 index 000000000..1ecc5e009 --- /dev/null +++ b/pkg/routes/api/v2/user_deletion.go @@ -0,0 +1,172 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userDeletionPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userDeletionConfirmBody struct { + Body struct { + Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."` + } +} + +func RegisterUserDeletionRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-deletion-request", + Summary: "Request account deletion", + Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.", + Method: http.MethodPost, + Path: "/user/deletion/request", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionRequest) + + Register(api, huma.Operation{ + OperationID: "user-deletion-confirm", + Summary: "Confirm account deletion", + Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.", + Method: http.MethodPost, + Path: "/user/deletion/confirm", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionConfirm) + + Register(api, huma.Operation{ + OperationID: "user-deletion-cancel", + Summary: "Cancel account deletion", + Description: "Cancels a scheduled account deletion. Local users must provide their password.", + Method: http.MethodPost, + Path: "/user/deletion/cancel", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionCancel) +} + +func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) } + +// authUserFromCtx resolves the full DB user for the authenticated caller, refusing +// link shares (which have no account to delete) with a 403. +func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + authUser, is := a.(*user.User) + if !is { + return nil, huma.Error403Forbidden("only users can manage account deletion") + } + // The auth user from the JWT claims is partial; re-fetch for the password hash. + u, err := user.GetUserByID(s, authUser.ID) + if err != nil { + return nil, translateDomainError(err) + } + return u, nil +} + +func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.RequestDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.CancelDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/user_export.go b/pkg/routes/api/v2/user_export.go new file mode 100644 index 000000000..952f8127b --- /dev/null +++ b/pkg/routes/api/v2/user_export.go @@ -0,0 +1,181 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/user" + webfiles "code.vikunja.io/api/pkg/web/files" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userExportPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userExportStatusBody struct { + Body *models.UserExportStatus +} + +func RegisterUserExportRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-export-request", + Summary: "Request a data export", + Description: "Starts building a full export of the authenticated user's data. Local users must confirm with their password. The export runs in the background; an email is sent when it is ready to download.", + Method: http.MethodPost, + Path: "/user/export/request", + Tags: tags, + DefaultStatus: http.StatusOK, + }, userExportRequest) + + Register(api, huma.Operation{ + OperationID: "user-export-download", + Summary: "Download the data export", + Description: "Streams the authenticated user's prepared data export as a zip file. Local users must confirm with their password. Fails with 404 if no export has been prepared. A POST (not GET) because the password is sent in the body.", + Method: http.MethodPost, + Path: "/user/export/download", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The data export as a zip file.", + Content: map[string]*huma.MediaType{ + "application/zip": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, userExportDownload) + + Register(api, huma.Operation{ + OperationID: "user-export-status", + Summary: "Get the current data export", + Description: "Returns metadata about the authenticated user's current data export (id, size, creation and expiry time), or null if none has been prepared.", + Method: http.MethodGet, + Path: "/user/export", + Tags: tags, + }, userExportStatus) +} + +func init() { AddRouteRegistrar(RegisterUserExportRoutes) } + +// confirmExportPassword resolves the full DB user and, for local accounts, verifies +// the supplied password — mirroring v1's checkExportRequest. External-provider users +// cannot supply a password and are passed through, as in v1. +func confirmExportPassword(ctx context.Context, s *xorm.Session, password string) (*user.User, error) { + u, err := authUserFromCtx(ctx, s) + if err != nil { + return nil, err + } + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, password); err != nil { + return nil, translateDomainError(err) + } + } + return u, nil +} + +func userExportRequest(ctx context.Context, in *userExportPasswordBody) (*messageBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchOnCommit(s, &models.UserDataExportRequestedEvent{User: u}) + + if err := s.Commit(); err != nil { + _ = s.Rollback() + events.CleanupPending(s) + return nil, translateDomainError(err) + } + events.DispatchPending(ctx, s) + + out := &messageBody{} + out.Body.Message = "Successfully requested data export. We will send you an email when it's ready." + return out, nil +} + +func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma.StreamResponse, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + exportFile, err := models.GetUserDataExportFile(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + // The stream callback (which closes the reader) won't run on this error path. + _ = exportFile.File.Close() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = exportFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteFileDownload((*c).Response(), (*c).Request(), exportFile) + }}, nil +} + +func userExportStatus(ctx context.Context, _ *struct{}) (*userExportStatusBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + status, err := models.GetUserDataExportStatus(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userExportStatusBody{Body: status}, nil +} diff --git a/pkg/routes/api/v2/user_search.go b/pkg/routes/api/v2/user_search.go new file mode 100644 index 000000000..5848ba5c0 --- /dev/null +++ b/pkg/routes/api/v2/user_search.go @@ -0,0 +1,120 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +type userListBody struct { + Body Paginated[*user.User] +} + +// RegisterUserSearchRoutes wires the two user-search endpoints onto the Huma API: +// a global search and a per-project search used for share autocomplete. +func RegisterUserSearchRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "users-search", + Summary: "Search users", + Description: "Searches users by username, name or full email. Matching by name or email requires the target user to have made themselves discoverable, unless both users share an external (OIDC/LDAP) team. Email addresses are never returned.", + Method: http.MethodGet, + Path: "/users", + Tags: []string{"user"}, + }, usersSearch) + + Register(api, huma.Operation{ + OperationID: "projects-users-search", + Summary: "Search users with access to a project", + Description: "Returns the users who can access the project — through ownership, a direct share or a team — optionally filtered by a search string. Intended for share autocomplete. Requires read access to the project.", + Method: http.MethodGet, + Path: "/projects/{project}/users/search", + Tags: []string{"sharing"}, + }, projectUsersSearch) +} + +func init() { AddRouteRegistrar(RegisterUserSearchRoutes) } + +func usersSearch(ctx context.Context, in *struct { + Q string `query:"q" doc:"Search query matched against username, name or full email."` +}) (*userListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + currentUser, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + users, err := user.SearchUsers(s, in.Q, currentUser) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil +} + +func projectUsersSearch(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + Q string `query:"q" doc:"Search query matched against username and name."` +}) (*userListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + currentUser, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + project := &models.Project{ID: in.ProjectID} + users, canRead, err := models.SearchUsersForProject(s, project, a, currentUser, in.Q) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !canRead { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil +} diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go new file mode 100644 index 000000000..35a366644 --- /dev/null +++ b/pkg/routes/api/v2/user_settings.go @@ -0,0 +1,334 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/tkuchiki/go-timezone" +) + +// userInfoBody is the GET /user response: the public user fields plus the +// computed account facts v1 returned alongside the user object. +type userInfoBody struct { + user.User + Settings *models.UserGeneralSettings `json:"settings" readOnly:"true" doc:"The current user's settings."` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at" readOnly:"true" doc:"When the account is scheduled for deletion, if a deletion was requested."` + IsLocalUser bool `json:"is_local_user" readOnly:"true" doc:"True if the user authenticates locally (not via LDAP or OpenID)."` + AuthProvider string `json:"auth_provider" readOnly:"true" doc:"The name of the source the user authenticated with: 'local', 'ldap', or the configured OpenID provider name."` + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"True if the user is an instance administrator."` +} + +// userAvatarProviderBody is the get/set body for the user's avatar provider. +type userAvatarProviderBody struct { + AvatarProvider string `json:"avatar_provider" doc:"The avatar provider. One of: gravatar (uses the user email), upload, initials, marble (random per user), ldap (synced from LDAP), openid (synced from OpenID), default."` +} + +type userActionMessageBody struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` +} + +// RegisterUserSettingsRoutes wires the current-user account & settings +// endpoints onto the Huma API. These are not CRUDable resources: each operates +// on the authenticated user pulled from the request context. +func RegisterUserSettingsRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-show", + Summary: "Get the current user", + Description: "Returns the authenticated user together with their settings and computed account facts (auth_provider, is_local_user, is_admin, deletion_scheduled_at).", + Method: http.MethodGet, + Path: "/user", + Tags: tags, + }, userShow) + + Register(api, huma.Operation{ + OperationID: "user-change-password", + Summary: "Change the current user's password", + Description: "Changes the authenticated user's password after verifying the old one. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password", + // Changes a password, it creates nothing — keep 200 over the wrapper's POST→201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, userChangePassword) + + Register(api, huma.Operation{ + OperationID: "user-update-email", + Summary: "Update the current user's email address", + Description: "Sets a new email address for the authenticated user after verifying their password. If the mailer is enabled the change is pending until the user confirms it via a link sent to the new address; otherwise it takes effect immediately.", + Method: http.MethodPut, + Path: "/user/settings/email", + Tags: tags, + }, userUpdateEmail) + + Register(api, huma.Operation{ + OperationID: "user-update-settings", + Summary: "Update the current user's general settings", + Description: "Replaces the authenticated user's general settings (name, reminders, discoverability, default project, week start, language, timezone, frontend settings).", + Method: http.MethodPut, + Path: "/user/settings/general", + Tags: tags, + }, userUpdateSettings) + + // Path differs from v1's /user/settings/avatar: on v2 that path is the + // binary avatar upload (PUT), so the provider get/set live on a sub-path. + Register(api, huma.Operation{ + OperationID: "user-get-avatar-provider", + Summary: "Get the current user's avatar provider", + Description: "Returns the avatar provider configured for the authenticated user.", + Method: http.MethodGet, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userGetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-set-avatar-provider", + Summary: "Set the current user's avatar provider", + Description: "Changes the avatar provider for the authenticated user. Valid values: gravatar, upload, initials, marble, ldap, openid, default.", + Method: http.MethodPut, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userSetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-timezones", + Summary: "List available time zones", + Description: "Returns every time zone this Vikunja instance can handle. The list depends on the host system and is unsorted; sort it client-side.", + Method: http.MethodGet, + Path: "/user/timezones", + Tags: tags, + }, userTimezones) +} + +func init() { AddRouteRegistrar(RegisterUserSettingsRoutes) } + +func userShow(ctx context.Context, _ *struct{}) (*singleBody[userInfoBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + return nil, translateDomainError(err) + } + + info := &userInfoBody{ + User: *u, + Settings: models.NewUserGeneralSettings(u), + DeletionScheduledAt: u.DeletionScheduledAt, + IsLocalUser: u.Issuer == user.IssuerLocal, + IsAdmin: u.IsAdmin, + } + + // nolint:contextcheck // openid.GetAllProviders/Issuer (called via shared) take + // no context; threading one would change those signatures across both APIs. + info.AuthProvider, err = shared.GetAuthProviderName(u) + if err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userInfoBody]{Body: info}, nil +} + +func userChangePassword(ctx context.Context, in *struct { + Body struct { + OldPassword string `json:"old_password" doc:"The current password, for confirmation."` + NewPassword string `json:"new_password" valid:"bcrypt_password" minLength:"8" maxLength:"72" doc:"The new password. Max 72 bytes (a bcrypt limit), which may be fewer than 72 characters."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := models.ChangeUserPassword(ctx, s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil +} + +func userUpdateEmail(ctx context.Context, in *struct { + Body struct { + NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."` + Password string `json:"password" doc:"The current password, for confirmation."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := user.ChangeUserEmail(ctx, s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil +} + +func userUpdateSettings(ctx context.Context, in *struct { + Body models.UserGeneralSettings +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil +} + +func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +func userSetAvatarProvider(ctx context.Context, in *struct { + Body userAvatarProviderBody +}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +type timezonesBody struct { + Body []string +} + +func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups + for _, group := range timezone.New().Timezones() { + for _, t := range group { + timezoneMap[t] = true + } + } + + ts := make([]string, 0, len(timezoneMap)) + for t := range timezoneMap { + ts = append(ts, t) + } + + return &timezonesBody{Body: ts}, nil +} diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go new file mode 100644 index 000000000..dd3b0c575 --- /dev/null +++ b/pkg/routes/api/v2/user_totp.go @@ -0,0 +1,255 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type totpStatusBody struct { + Body *user.TOTP +} + +type totpEnableBody struct { + Body struct { + Passcode string `json:"passcode" doc:"The current totp passcode, used to confirm the authenticator is set up correctly."` + } +} + +type totpDisableBody struct { + Body struct { + Password string `json:"password" doc:"The current user's password, required to disable totp."` + } +} + +type totpMessageBody struct { + Body models.Message +} + +// totpQrCodeResponse carries the qr code jpeg bytes plus a fixed Content-Type. +// Huma writes the []byte Body straight to the wire; the header field overrides +// content negotiation so image/jpeg reaches the client (matching v1). +type totpQrCodeResponse struct { + ContentType string `header:"Content-Type"` + Body []byte +} + +// RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma +// API. Totp is a local-account feature; every handler rejects OIDC/LDAP users. +func RegisterTOTPRoutes(api huma.API) { + if !config.ServiceEnableTotp.GetBool() { + return + } + + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "totp-get", + Summary: "Get totp status", + Description: "Returns the authenticated user's current totp setting. Fails with 412 if totp was never enrolled. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp", + Tags: tags, + }, totpGet) + + Register(api, huma.Operation{ + OperationID: "totp-enroll", + Summary: "Enroll into totp", + Description: "Creates the totp secret for the authenticated user. The setup must still be confirmed via the enable endpoint before it takes effect. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enroll", + // v1 returns 200 here, not 201: enrollment is an inactive draft, not a usable resource yet. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnroll) + + Register(api, huma.Operation{ + OperationID: "totp-enable", + Summary: "Enable totp", + Description: "Activates a previously enrolled totp setting by confirming a passcode. All existing sessions are invalidated. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enable", + // Confirms an existing enrollment; creates no new resource. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnable) + + Register(api, huma.Operation{ + OperationID: "totp-disable", + Summary: "Disable totp", + Description: "Removes all totp settings for the authenticated user. Requires the current password for confirmation. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/disable", + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpDisable) + + Register(api, huma.Operation{ + OperationID: "totp-qrcode", + Summary: "Get the totp enrollment qr code", + Description: "Returns the qr code for the authenticated user's enrolled totp setting as a jpeg image, for scanning into an authenticator app. Requires a prior enrollment. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp/qrcode", + Tags: tags, + // Spell out the binary response; a bare []byte Body would otherwise be + // modeled as a base64 JSON string instead of binary image data. + Responses: map[string]*huma.Response{ + "200": { + Description: "The qr code as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, totpQrCode) +} + +func init() { AddRouteRegistrar(RegisterTOTPRoutes) } + +// localUserFromCtx resolves the authenticated user and refuses anything that is +// not a local account, mirroring v1's getLocalUserFromContext. The caller owns +// the returned session. CheckUserPassword and IsLocalUser need the full DB +// record (password hash, issuer), so this loads it rather than trusting the +// token claims. +func localUserFromCtx(ctx context.Context) (*user.User, *xorm.Session, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, nil, err + } + + s := db.NewSession() + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + s.Close() + return nil, nil, translateDomainError(err) + } + // A link share resolves to a synthetic, non-local user; any other auth type + // yields nil. Both must be refused — totp is a real-account-only feature. + if u == nil || !u.IsLocalUser() { + s.Close() + return nil, nil, translateDomainError(&user.ErrAccountIsNotLocal{}) + } + + return u, s, nil +} + +func totpGet(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.GetTOTPForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnroll(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.EnrollTOTP(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnable(ctx context.Context, in *totpEnableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.EnableTOTP(s, &user.TOTPPasscode{User: u, Passcode: in.Body.Passcode}); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.DeleteAllUserSessions(s, u.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was enabled successfully."}}, nil +} + +func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := user.DisableTOTP(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil +} + +func totpQrCode(ctx context.Context, _ *struct{}) (*totpQrCodeResponse, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpQrCodeResponse{ContentType: "image/jpeg", Body: qrcode}, nil +} diff --git a/pkg/routes/api/v2/user_webhooks.go b/pkg/routes/api/v2/user_webhooks.go new file mode 100644 index 000000000..b35407c79 --- /dev/null +++ b/pkg/routes/api/v2/user_webhooks.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// models.Webhook.ReadAll returns []*models.Webhook, so that's the element type. +type userWebhookListBody struct { + Body Paginated[*models.Webhook] +} + +type userWebhookEventsBody struct { + Body []string +} + +// RegisterUserWebhookRoutes wires the per-user webhook CRUD onto the Huma API. +// User webhooks are the project-less sibling of the project webhooks (see +// webhooks.go): they fire across all of a user's projects and are owned by the +// user, not a project. Both resources share the webhooks.enabled gate; the check +// runs here (not at init()) because RegisterAll fires after config is loaded. +// Like project webhooks there is deliberately no ReadOne — webhooks carry +// credentials — so AutoPatch synthesises no PATCH and update is PUT only. +func RegisterUserWebhookRoutes(api huma.API) { + if !config.WebhooksEnabled.GetBool() { + return + } + + tags := []string{"webhooks"} + + Register(api, huma.Operation{ + OperationID: "user-webhooks-list", + Summary: "List the current user's webhooks", + Description: "Returns the webhook targets the authenticated user has configured for themselves (not project webhooks), paginated. Secret and basic-auth credentials are never included.", + Method: http.MethodGet, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksList) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-events", + Summary: "List available user-directed webhook events", + Description: "Returns the webhook event names a user-level webhook may subscribe to. This is a subset of the project webhook events — only events that target a single user.", + Method: http.MethodGet, + Path: "/user/settings/webhooks/events", + Tags: tags, + }, userWebhooksEvents) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-create", + Summary: "Create a webhook for the current user", + Description: "Creates a webhook target owned by the authenticated user that receives POST requests across all of their projects. The owning user is taken from the token, not the body. May only subscribe to user-directed events (see the events route). The secret and basic-auth credentials are write-only and not returned in the response.", + Method: http.MethodPost, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksCreate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-update", + Summary: "Update a user webhook's events", + Description: "Changes the events a user webhook subscribes to. Only the events list can be changed; target_url, secret and auth are immutable after creation. Only the owning user may update it.", + Method: http.MethodPut, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksUpdate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-delete", + Summary: "Delete a user webhook", + Description: "Deletes a webhook owned by the authenticated user. Only the owning user may delete it.", + Method: http.MethodDelete, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksDelete) +} + +func init() { AddRouteRegistrar(RegisterUserWebhookRoutes) } + +func userWebhooksList(ctx context.Context, in *ListParams) (*userWebhookListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.Webhook{UserID: a.GetID()}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.Webhook) + if !ok { + return nil, fmt.Errorf("webhooks.ReadAll returned unexpected type %T (expected []*models.Webhook)", result) + } + return &userWebhookListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} + +func userWebhooksEvents(_ context.Context, _ *struct{}) (*userWebhookEventsBody, error) { + return &userWebhookEventsBody{Body: models.GetUserDirectedWebhookEvents()}, nil +} + +func userWebhooksCreate(ctx context.Context, in *struct { + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // Force user ownership: a user webhook is keyed on the user, never a project. + in.Body.UserID = a.GetID() + in.Body.ProjectID = 0 + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksUpdate(ctx context.Context, in *struct { + ID int64 `path:"webhook"` + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // canDoWebhook resolves the owner from the stored row, so only the id is + // needed to gate the update; the rest of the body's ownership fields are + // ignored. Update persists only the events list. + in.Body.ID = in.ID + if err := handler.DoUpdate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksDelete(ctx context.Context, in *struct { + ID int64 `path:"webhook"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.Webhook{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/webhook_events.go b/pkg/routes/api/v2/webhook_events.go new file mode 100644 index 000000000..56ad57873 --- /dev/null +++ b/pkg/routes/api/v2/webhook_events.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + + "github.com/danielgtaylor/huma/v2" +) + +type webhookEventsBody struct { + Body []string `json:"events" doc:"The events a webhook target can subscribe to."` +} + +// RegisterWebhookEventRoutes wires the available-webhook-events listing onto the +// Huma API. Like v1, the whole endpoint only exists when webhooks are enabled. +func RegisterWebhookEventRoutes(api huma.API) { + if !config.WebhooksEnabled.GetBool() { + return + } + + Register(api, huma.Operation{ + OperationID: "webhooks-events-list", + Summary: "List available webhook events", + Description: "Returns every event a webhook target can subscribe to. Use these values when creating or updating a webhook.", + Method: http.MethodGet, + Path: "/webhooks/events", + Tags: []string{"webhooks"}, + }, webhookEventsList) +} + +func init() { AddRouteRegistrar(RegisterWebhookEventRoutes) } + +func webhookEventsList(_ context.Context, _ *struct{}) (*webhookEventsBody, error) { + return &webhookEventsBody{Body: models.GetAvailableWebhookEvents()}, nil +} diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 0c9708849..4f146e5a7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,6 +21,7 @@ import ( "strings" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk c.Set("api_token", token) c.Set("api_user", u) + // Guarded by config: this fires on every token-authenticated request and + // only the audit listener consumes it. + if config.AuditEnabled.GetBool() { + err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{ + TokenID: token.ID, + OwnerID: token.OwnerID, + }) + if err != nil { + log.Errorf("Could not dispatch api token used event: %s", err) + } + } + return nil } diff --git a/pkg/routes/caldav/auth.go b/pkg/routes/caldav/auth.go index 930b8f013..fc89d7555 100644 --- a/pkg/routes/caldav/auth.go +++ b/pkg/routes/caldav/auth.go @@ -88,7 +88,7 @@ func BasicAuth(c *echo.Context, username, password string) (bool, error) { return false, nil } if u == nil { - u, err = user.CheckUserCredentials(s, credentials) + u, err = user.CheckUserCredentials(c.Request().Context(), s, credentials) if err != nil { log.Errorf("Error during basic auth for caldav: %v", err) return false, nil diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 5544d3ec7..60a151e2d 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -17,6 +17,7 @@ package caldav import ( + "context" "slices" "strconv" "strings" @@ -396,7 +397,7 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -473,7 +474,7 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -516,7 +517,7 @@ func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error { return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) } return nil diff --git a/pkg/routes/feeds/auth.go b/pkg/routes/feeds/auth.go index 419fd2ccd..a0142317b 100644 --- a/pkg/routes/feeds/auth.go +++ b/pkg/routes/feeds/auth.go @@ -23,9 +23,9 @@ import ( "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" - "xorm.io/xorm" "github.com/labstack/echo/v5" + "xorm.io/xorm" ) func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) { @@ -50,35 +50,48 @@ func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) return u, nil } -// BasicAuth authenticates feed requests. Only API tokens are accepted — -// password and LDAP credentials are rejected outright because feed URLs are -// commonly exported, shared, or cached by feed readers. -func BasicAuth(c *echo.Context, username, password string) (bool, error) { +// AuthenticateFeedToken validates feed credentials against an existing session. +// Only API tokens are accepted — password and LDAP credentials are rejected +// outright because feed URLs are commonly exported, shared, or cached by feed +// readers. It returns the authenticated user, or nil for any rejection so +// callers can treat "invalid" and "unknown" identically. +func AuthenticateFeedToken(s *xorm.Session, username, password string) (*user.User, error) { if !strings.HasPrefix(password, models.APITokenPrefix) { - return false, nil + return nil, nil } // GetTokenFromTokenString slices password[len-8:] without a length check, // so a stray "tk_" or other short prefix-only string would panic before // the credentials could be rejected. Real tokens are far longer than // prefix+8, so anything shorter is invalid by construction. if len(password) < len(models.APITokenPrefix)+8 { - return false, nil + return nil, nil } - s := db.NewSession() - defer s.Close() - u, err := checkAPIToken(s, username, password) if err != nil { log.Errorf("Error during API token auth for feeds: %v", err) - return false, nil + return nil, nil } if u == nil { - return false, nil + return nil, nil } if u.IsBot() { log.Warningf("Feed auth rejected for bot user %d", u.ID) - return false, nil + return nil, nil + } + + return u, nil +} + +// BasicAuth authenticates feed requests for echo's BasicAuth middleware. The +// validation logic is shared with the v2 handler via AuthenticateFeedToken. +func BasicAuth(c *echo.Context, username, password string) (bool, error) { + s := db.NewSession() + defer s.Close() + + u, err := AuthenticateFeedToken(s, username, password) + if err != nil || u == nil { + return false, err } c.Set("userBasicAuth", u) diff --git a/pkg/routes/feeds/handler.go b/pkg/routes/feeds/handler.go index 9d0794c76..2a5ce289d 100644 --- a/pkg/routes/feeds/handler.go +++ b/pkg/routes/feeds/handler.go @@ -30,24 +30,22 @@ import ( "github.com/gorilla/feeds" "github.com/labstack/echo/v5" + "xorm.io/xorm" ) const feedItemLimit = 50 -// NotificationsAtomFeed serves the authenticated user's notifications as an -// Atom feed. Notifications are not marked as read by being fetched here. -func NotificationsAtomFeed(c *echo.Context) error { - u, ok := c.Get("userBasicAuth").(*user.User) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) - } - - s := db.NewSession() - defer s.Close() +// AtomContentType is the content type of the notifications Atom feed. Shared so +// the v1 echo handler and the v2 Huma op set the same header. +const AtomContentType = "application/atom+xml; charset=utf-8" +// BuildNotificationsAtomFeed renders the user's latest notifications as Atom XML +// against an existing session. Notifications are not marked as read by being +// fetched here. Shared by the v1 echo handler and the v2 Huma op. +func BuildNotificationsAtomFeed(s *xorm.Session, u *user.User) (string, error) { rows, _, _, err := notifications.GetNotificationsForUser(s, u.ID, feedItemLimit, 0) if err != nil { - return err + return "", err } publicURL := config.ServicePublicURL.GetString() @@ -85,11 +83,25 @@ func NotificationsAtomFeed(c *echo.Context) error { }) } - atom, err := feed.ToAtom() + return feed.ToAtom() +} + +// NotificationsAtomFeed serves the authenticated user's notifications as an +// Atom feed. Notifications are not marked as read by being fetched here. +func NotificationsAtomFeed(c *echo.Context) error { + u, ok := c.Get("userBasicAuth").(*user.User) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) + } + + s := db.NewSession() + defer s.Close() + + atom, err := BuildNotificationsAtomFeed(s, u) if err != nil { return err } - c.Response().Header().Set(echo.HeaderContentType, "application/atom+xml; charset=utf-8") + c.Response().Header().Set(echo.HeaderContentType, AtomContentType) return c.String(http.StatusOK, atom) } diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go new file mode 100644 index 000000000..865cd5cde --- /dev/null +++ b/pkg/routes/middleware/request_meta.go @@ -0,0 +1,42 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import ( + "code.vikunja.io/api/pkg/events" + + "github.com/labstack/echo/v5" +) + +// RequestMeta stashes IP, User-Agent and the request ID on the request +// context so events dispatched while handling the request carry them as +// message metadata (consumed by the audit listeners). Must run after the +// RequestID middleware, which guarantees the response header is populated. +func RequestMeta() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ + IP: c.RealIP(), + UserAgent: req.UserAgent(), + RequestID: c.Response().Header().Get(echo.HeaderXRequestID), + }) + c.SetRequest(req.WithContext(ctx)) + return next(c) + } + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 159994724..bcdea1bdb 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -155,6 +155,11 @@ func NewEcho() *echo.Echo { e.Logger = log.NewEchoLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString()) + // First middleware in the chain so every request has an ID — reuses the + // X-Request-Id header from a proxy or generates one — and everything + // downstream (logging, audit) sees the same value. + e.Use(middleware.RequestID()) + // Logger if config.LogEnabled.GetBool() && config.LogHTTP.GetString() != "off" { httpLogger := log.NewHTTPLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString()) @@ -199,6 +204,10 @@ func NewEcho() *echo.Echo { // handler binds them. Runs globally so both /api/v1 and /api/v2 benefit. e.Use(vmiddleware.NormalizeArrayParams()) + if config.AuditEnabled.GetBool() { + e.Use(vmiddleware.RequestMeta()) + } + setupSentry(e) // Validation @@ -343,6 +352,33 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/docs": true, "/api/v2/docs/scalar.standalone.js": true, "/api/v2/schemas/:schema": true, + "/api/v2/info": true, + + "/api/v2/register": true, + "/api/v2/user/password/token": true, + "/api/v2/user/password/reset": true, + "/api/v2/user/confirm": true, + "/api/v2/shares/:share/auth": true, + "/api/v2/oauth/token": true, + "/api/v2/login": true, + "/api/v2/user/token/refresh": true, + "/api/v2/auth/openid/:provider/callback": true, + + // Testing endpoints authenticate with the testing token via a custom + // Authorization header, not a JWT; mounted only when that token is set. + "/api/v2/test/all": true, + "/api/v2/test/:table": true, + + // Public infra healthcheck (a Huma op that opts out of the global auth). + "/api/v2/health": true, + + // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a + // feeds-scoped API token), like its /feeds counterpart, not a JWT. + "/api/v2/notifications.atom": true, + + // WebSocket upgrade (a raw echo route — OpenAPI can't model WebSockets); + // it authenticates via its first message, so the upgrade needs no JWT. + "/api/v2/ws": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. @@ -415,6 +451,13 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { a.GET("/docs", apiv2.ScalarUI) a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS) + // WebSockets can't be modeled in OpenAPI and Huma has no WS support, so the + // upgrade endpoint stays a raw echo route (outside the Huma spec). It + // authenticates via its first message, so unauthenticatedAPIPaths exempts it + // from the group's JWT middleware. Health and the Atom feed are Huma ops and + // self-register via init()/RegisterAll. + a.GET("/ws", ws.UpgradeHandler) + // Resources self-register via init(); RegisterAll runs them all + AutoPatch. apiv2.RegisterAll(api) } diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index eac2622f5..93de88c59 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -42,7 +42,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.Overview" + "$ref": "#/definitions/models.Overview" } }, "404": { @@ -207,7 +207,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } } }, @@ -243,7 +243,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.CreateUserBody" + "$ref": "#/definitions/models.CreateUserBody" } } ], @@ -251,7 +251,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -352,7 +352,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -410,7 +410,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -836,7 +836,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -3438,7 +3438,7 @@ const docTemplate = `{ "JWTKeyAuth": [] } ], - "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.", "consumes": [ "application/json" ], @@ -7342,7 +7342,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.UserExportStatus" + "$ref": "#/definitions/models.UserExportStatus" } } } @@ -7456,7 +7456,7 @@ const docTemplate = `{ }, "/user/logout": { "post": { - "description": "Destroys the current session and clears the refresh token cookie.", + "description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an ` + "`" + `oidc_logout_url` + "`" + ` the client should redirect to so the provider session is ended too.", "produces": [ "application/json" ], @@ -7468,7 +7468,7 @@ const docTemplate = `{ "200": { "description": "Successfully logged out.", "schema": { - "$ref": "#/definitions/models.Message" + "$ref": "#/definitions/v1.LogoutResponse" } } } @@ -7848,7 +7848,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -8884,44 +8884,6 @@ const docTemplate = `{ } }, "definitions": { - "admin.CreateUserBody": { - "type": "object", - "properties": { - "email": { - "description": "The user's email address", - "type": "string", - "maxLength": 250 - }, - "is_admin": { - "description": "Mark the new user as an instance admin.", - "type": "boolean" - }, - "language": { - "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", - "type": "string" - }, - "name": { - "description": "The full name of the new user. Optional.", - "type": "string" - }, - "password": { - "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", - "type": "string", - "maxLength": 72, - "minLength": 8 - }, - "skip_email_confirm": { - "description": "Activate the new user immediately without email confirmation.", - "type": "boolean" - }, - "username": { - "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", - "type": "string", - "maxLength": 250, - "minLength": 3 - } - } - }, "admin.IsAdminPatch": { "type": "object", "properties": { @@ -8931,29 +8893,6 @@ const docTemplate = `{ } } }, - "admin.Overview": { - "type": "object", - "properties": { - "license": { - "$ref": "#/definitions/license.Info" - }, - "projects": { - "type": "integer" - }, - "shares": { - "$ref": "#/definitions/admin.ShareCounts" - }, - "tasks": { - "type": "integer" - }, - "teams": { - "type": "integer" - }, - "users": { - "type": "integer" - } - } - }, "admin.OwnerPatch": { "type": "object", "properties": { @@ -8962,20 +8901,6 @@ const docTemplate = `{ } } }, - "admin.ShareCounts": { - "type": "object", - "properties": { - "link_shares": { - "type": "integer" - }, - "team_shares": { - "type": "integer" - }, - "user_shares": { - "type": "integer" - } - } - }, "admin.StatusPatch": { "type": "object", "properties": { @@ -8989,57 +8914,6 @@ const docTemplate = `{ } } }, - "admin.User": { - "type": "object", - "properties": { - "auth_provider": { - "type": "string" - }, - "bot_owner_id": { - "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", - "type": "integer" - }, - "created": { - "description": "A timestamp when this task was created. You cannot change this value.", - "type": "string" - }, - "email": { - "description": "The user's email address.", - "type": "string", - "maxLength": 250 - }, - "id": { - "description": "The unique, numeric id of this user.", - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "issuer": { - "type": "string" - }, - "name": { - "description": "The full name of the user.", - "type": "string" - }, - "status": { - "$ref": "#/definitions/user.Status" - }, - "subject": { - "type": "string" - }, - "updated": { - "description": "A timestamp when this task was last updated. You cannot change this value.", - "type": "string" - }, - "username": { - "description": "The username of the user. Is always unique.", - "type": "string", - "maxLength": 250, - "minLength": 1 - } - } - }, "auth.Token": { "type": "object", "properties": { @@ -9470,6 +9344,44 @@ const docTemplate = `{ } } }, + "models.CreateUserBody": { + "type": "object", + "properties": { + "email": { + "description": "The user's email address", + "type": "string", + "maxLength": 250 + }, + "is_admin": { + "description": "Mark the new user as an instance admin.", + "type": "boolean" + }, + "language": { + "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", + "type": "string" + }, + "name": { + "description": "The full name of the new user. Optional.", + "type": "string" + }, + "password": { + "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", + "type": "string", + "maxLength": 72, + "minLength": 8 + }, + "skip_email_confirm": { + "description": "Activate the new user immediately without email confirmation.", + "type": "boolean" + }, + "username": { + "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", + "type": "string", + "maxLength": 250, + "minLength": 3 + } + } + }, "models.DatabaseNotifications": { "type": "object", "properties": { @@ -9629,6 +9541,29 @@ const docTemplate = `{ } } }, + "models.Overview": { + "type": "object", + "properties": { + "license": { + "$ref": "#/definitions/license.Info" + }, + "projects": { + "type": "integer" + }, + "shares": { + "$ref": "#/definitions/models.ShareCounts" + }, + "tasks": { + "type": "integer" + }, + "teams": { + "type": "integer" + }, + "users": { + "type": "integer" + } + } + }, "models.Permission": { "type": "integer", "enum": [ @@ -9730,6 +9665,10 @@ const docTemplate = `{ "models.ProjectDuplicate": { "type": "object", "properties": { + "duplicate_shares": { + "description": "Whether to copy the project's shares to the duplicate", + "type": "boolean" + }, "duplicated_project": { "description": "The copied project", "allOf": [ @@ -9876,7 +9815,8 @@ const docTemplate = `{ }, "value": { "description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.", - "type": "string" + "type": "string", + "maxLength": 20 } } }, @@ -10000,6 +9940,20 @@ const docTemplate = `{ } } }, + "models.ShareCounts": { + "type": "object", + "properties": { + "link_shares": { + "type": "integer" + }, + "team_shares": { + "type": "integer" + }, + "user_shares": { + "type": "integer" + } + } + }, "models.SharingType": { "type": "integer", "enum": [ @@ -10202,6 +10156,10 @@ const docTemplate = `{ } ] }, + "time_entries_count": { + "description": "Time entry count of this task. Only present when fetching tasks with the ` + "`" + `expand` + "`" + ` parameter set to ` + "`" + `time_entries_count` + "`" + `.", + "type": "integer" + }, "title": { "description": "The task text. This is what you'll see in the project.", "type": "string", @@ -10369,7 +10327,7 @@ const docTemplate = `{ "type": "integer" }, "relation_kind": { - "description": "The kind of the relation.", + "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.", "allOf": [ { "$ref": "#/definitions/models.RelationKind" @@ -10625,6 +10583,66 @@ const docTemplate = `{ } } }, + "models.UserExportStatus": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "expires": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "size": { + "type": "integer" + } + } + }, + "models.UserGeneralSettings": { + "type": "object", + "properties": { + "default_project_id": { + "type": "integer" + }, + "discoverable_by_email": { + "type": "boolean" + }, + "discoverable_by_name": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "extra_settings_links": { + "description": "Server/OpenID-provided; populated on read, ignored on write.", + "type": "object", + "additionalProperties": {} + }, + "frontend_settings": {}, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "overdue_tasks_reminders_enabled": { + "type": "boolean" + }, + "overdue_tasks_reminders_time": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "week_start": { + "type": "integer", + "maximum": 6, + "minimum": 0 + } + } + }, "models.UserWithPermission": { "type": "object", "properties": { @@ -10761,6 +10779,196 @@ const docTemplate = `{ } } }, + "shared.AdminUser": { + "type": "object", + "properties": { + "auth_provider": { + "type": "string" + }, + "bot_owner_id": { + "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", + "type": "integer" + }, + "created": { + "description": "A timestamp when this task was created. You cannot change this value.", + "type": "string" + }, + "email": { + "description": "The user's email address.", + "type": "string", + "maxLength": 250 + }, + "id": { + "description": "The unique, numeric id of this user.", + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "issuer": { + "type": "string" + }, + "name": { + "description": "The full name of the user.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/user.Status" + }, + "subject": { + "type": "string" + }, + "updated": { + "description": "A timestamp when this task was last updated. You cannot change this value.", + "type": "string" + }, + "username": { + "description": "The username of the user. Is always unique.", + "type": "string", + "maxLength": 250, + "minLength": 1 + } + } + }, + "shared.AuthInfo": { + "type": "object", + "properties": { + "ldap": { + "$ref": "#/definitions/shared.LdapAuthInfo" + }, + "local": { + "$ref": "#/definitions/shared.LocalAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/shared.OpenIDAuthInfo" + } + } + }, + "shared.LdapAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "shared.LegalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, + "shared.LocalAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "registration_enabled": { + "type": "boolean" + } + } + }, + "shared.OpenIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" + } + } + } + }, + "shared.VikunjaInfos": { + "type": "object", + "properties": { + "allow_icon_changes": { + "type": "boolean" + }, + "auth": { + "$ref": "#/definitions/shared.AuthInfo" + }, + "available_migrators": { + "type": "array", + "items": { + "type": "string" + } + }, + "caldav_enabled": { + "type": "boolean" + }, + "concurrent_writes": { + "description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.", + "type": "boolean" + }, + "demo_mode_enabled": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "enabled_background_providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled_pro_features": { + "type": "array", + "items": { + "$ref": "#/definitions/license.Feature" + } + }, + "frontend_url": { + "type": "string" + }, + "legal": { + "$ref": "#/definitions/shared.LegalInfo" + }, + "link_sharing_enabled": { + "type": "boolean" + }, + "max_file_size": { + "type": "string" + }, + "max_items_per_page": { + "type": "integer" + }, + "motd": { + "type": "string" + }, + "public_teams_enabled": { + "type": "boolean" + }, + "task_attachments_enabled": { + "type": "boolean" + }, + "task_comments_enabled": { + "type": "boolean" + }, + "totp_enabled": { + "type": "boolean" + }, + "user_deletion_enabled": { + "type": "boolean" + }, + "version": { + "type": "string" + }, + "webhooks_enabled": { + "type": "boolean" + } + } + }, "todoist.Migration": { "type": "object", "properties": { @@ -10941,6 +11149,18 @@ const docTemplate = `{ } } }, + "v1.LogoutResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "oidc_logout_url": { + "description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.", + "type": "string" + } + } + }, "v1.UserAvatarProvider": { "type": "object", "properties": { @@ -10958,23 +11178,6 @@ const docTemplate = `{ } } }, - "v1.UserExportStatus": { - "type": "object", - "properties": { - "created": { - "type": "string" - }, - "expires": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "size": { - "type": "integer" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { @@ -11022,59 +11225,6 @@ const docTemplate = `{ } } }, - "v1.UserSettings": { - "type": "object", - "properties": { - "default_project_id": { - "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.", - "type": "integer" - }, - "discoverable_by_email": { - "description": "If true, the user can be found when searching for their exact email.", - "type": "boolean" - }, - "discoverable_by_name": { - "description": "If true, this user can be found by their name or parts of it when searching for it.", - "type": "boolean" - }, - "email_reminders_enabled": { - "description": "If enabled, sends email reminders of tasks to the user.", - "type": "boolean" - }, - "extra_settings_links": { - "description": "Additional settings links as provided by openid", - "type": "object", - "additionalProperties": {} - }, - "frontend_settings": { - "description": "Additional settings only used by the frontend" - }, - "language": { - "description": "The user's language", - "type": "string" - }, - "name": { - "description": "The new name of the current user.", - "type": "string" - }, - "overdue_tasks_reminders_enabled": { - "description": "If enabled, the user will get an email for their overdue tasks each morning.", - "type": "boolean" - }, - "overdue_tasks_reminders_time": { - "description": "The time when the daily summary of overdue tasks will be sent via email.", - "type": "string" - }, - "timezone": { - "description": "The user's time zone. Used to send task reminders in the time zone of the user.", - "type": "string" - }, - "week_start": { - "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.", - "type": "integer" - } - } - }, "v1.UserWithSettings": { "type": "object", "properties": { @@ -11112,7 +11262,7 @@ const docTemplate = `{ "type": "string" }, "settings": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -11126,141 +11276,6 @@ const docTemplate = `{ } } }, - "v1.authInfo": { - "type": "object", - "properties": { - "ldap": { - "$ref": "#/definitions/v1.ldapAuthInfo" - }, - "local": { - "$ref": "#/definitions/v1.localAuthInfo" - }, - "openid_connect": { - "$ref": "#/definitions/v1.openIDAuthInfo" - } - } - }, - "v1.ldapAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "v1.legalInfo": { - "type": "object", - "properties": { - "imprint_url": { - "type": "string" - }, - "privacy_policy_url": { - "type": "string" - } - } - }, - "v1.localAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "registration_enabled": { - "type": "boolean" - } - } - }, - "v1.openIDAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "providers": { - "type": "array", - "items": { - "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" - } - } - } - }, - "v1.vikunjaInfos": { - "type": "object", - "properties": { - "allow_icon_changes": { - "type": "boolean" - }, - "auth": { - "$ref": "#/definitions/v1.authInfo" - }, - "available_migrators": { - "type": "array", - "items": { - "type": "string" - } - }, - "caldav_enabled": { - "type": "boolean" - }, - "demo_mode_enabled": { - "type": "boolean" - }, - "email_reminders_enabled": { - "type": "boolean" - }, - "enabled_background_providers": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled_pro_features": { - "type": "array", - "items": { - "$ref": "#/definitions/license.Feature" - } - }, - "frontend_url": { - "type": "string" - }, - "legal": { - "$ref": "#/definitions/v1.legalInfo" - }, - "link_sharing_enabled": { - "type": "boolean" - }, - "max_file_size": { - "type": "string" - }, - "max_items_per_page": { - "type": "integer" - }, - "motd": { - "type": "string" - }, - "public_teams_enabled": { - "type": "boolean" - }, - "task_attachments_enabled": { - "type": "boolean" - }, - "task_comments_enabled": { - "type": "boolean" - }, - "totp_enabled": { - "type": "boolean" - }, - "user_deletion_enabled": { - "type": "boolean" - }, - "version": { - "type": "string" - }, - "webhooks_enabled": { - "type": "boolean" - } - } - }, "web.HTTPError": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 645ebb2cb..f02bf9bde 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -34,7 +34,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.Overview" + "$ref": "#/definitions/models.Overview" } }, "404": { @@ -199,7 +199,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } } }, @@ -235,7 +235,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.CreateUserBody" + "$ref": "#/definitions/models.CreateUserBody" } } ], @@ -243,7 +243,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -344,7 +344,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -402,7 +402,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -828,7 +828,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -3430,7 +3430,7 @@ "JWTKeyAuth": [] } ], - "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.", "consumes": [ "application/json" ], @@ -7334,7 +7334,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.UserExportStatus" + "$ref": "#/definitions/models.UserExportStatus" } } } @@ -7448,7 +7448,7 @@ }, "/user/logout": { "post": { - "description": "Destroys the current session and clears the refresh token cookie.", + "description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.", "produces": [ "application/json" ], @@ -7460,7 +7460,7 @@ "200": { "description": "Successfully logged out.", "schema": { - "$ref": "#/definitions/models.Message" + "$ref": "#/definitions/v1.LogoutResponse" } } } @@ -7840,7 +7840,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -8876,44 +8876,6 @@ } }, "definitions": { - "admin.CreateUserBody": { - "type": "object", - "properties": { - "email": { - "description": "The user's email address", - "type": "string", - "maxLength": 250 - }, - "is_admin": { - "description": "Mark the new user as an instance admin.", - "type": "boolean" - }, - "language": { - "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", - "type": "string" - }, - "name": { - "description": "The full name of the new user. Optional.", - "type": "string" - }, - "password": { - "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", - "type": "string", - "maxLength": 72, - "minLength": 8 - }, - "skip_email_confirm": { - "description": "Activate the new user immediately without email confirmation.", - "type": "boolean" - }, - "username": { - "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", - "type": "string", - "maxLength": 250, - "minLength": 3 - } - } - }, "admin.IsAdminPatch": { "type": "object", "properties": { @@ -8923,29 +8885,6 @@ } } }, - "admin.Overview": { - "type": "object", - "properties": { - "license": { - "$ref": "#/definitions/license.Info" - }, - "projects": { - "type": "integer" - }, - "shares": { - "$ref": "#/definitions/admin.ShareCounts" - }, - "tasks": { - "type": "integer" - }, - "teams": { - "type": "integer" - }, - "users": { - "type": "integer" - } - } - }, "admin.OwnerPatch": { "type": "object", "properties": { @@ -8954,20 +8893,6 @@ } } }, - "admin.ShareCounts": { - "type": "object", - "properties": { - "link_shares": { - "type": "integer" - }, - "team_shares": { - "type": "integer" - }, - "user_shares": { - "type": "integer" - } - } - }, "admin.StatusPatch": { "type": "object", "properties": { @@ -8981,57 +8906,6 @@ } } }, - "admin.User": { - "type": "object", - "properties": { - "auth_provider": { - "type": "string" - }, - "bot_owner_id": { - "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", - "type": "integer" - }, - "created": { - "description": "A timestamp when this task was created. You cannot change this value.", - "type": "string" - }, - "email": { - "description": "The user's email address.", - "type": "string", - "maxLength": 250 - }, - "id": { - "description": "The unique, numeric id of this user.", - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "issuer": { - "type": "string" - }, - "name": { - "description": "The full name of the user.", - "type": "string" - }, - "status": { - "$ref": "#/definitions/user.Status" - }, - "subject": { - "type": "string" - }, - "updated": { - "description": "A timestamp when this task was last updated. You cannot change this value.", - "type": "string" - }, - "username": { - "description": "The username of the user. Is always unique.", - "type": "string", - "maxLength": 250, - "minLength": 1 - } - } - }, "auth.Token": { "type": "object", "properties": { @@ -9462,6 +9336,44 @@ } } }, + "models.CreateUserBody": { + "type": "object", + "properties": { + "email": { + "description": "The user's email address", + "type": "string", + "maxLength": 250 + }, + "is_admin": { + "description": "Mark the new user as an instance admin.", + "type": "boolean" + }, + "language": { + "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", + "type": "string" + }, + "name": { + "description": "The full name of the new user. Optional.", + "type": "string" + }, + "password": { + "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", + "type": "string", + "maxLength": 72, + "minLength": 8 + }, + "skip_email_confirm": { + "description": "Activate the new user immediately without email confirmation.", + "type": "boolean" + }, + "username": { + "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", + "type": "string", + "maxLength": 250, + "minLength": 3 + } + } + }, "models.DatabaseNotifications": { "type": "object", "properties": { @@ -9621,6 +9533,29 @@ } } }, + "models.Overview": { + "type": "object", + "properties": { + "license": { + "$ref": "#/definitions/license.Info" + }, + "projects": { + "type": "integer" + }, + "shares": { + "$ref": "#/definitions/models.ShareCounts" + }, + "tasks": { + "type": "integer" + }, + "teams": { + "type": "integer" + }, + "users": { + "type": "integer" + } + } + }, "models.Permission": { "type": "integer", "enum": [ @@ -9722,6 +9657,10 @@ "models.ProjectDuplicate": { "type": "object", "properties": { + "duplicate_shares": { + "description": "Whether to copy the project's shares to the duplicate", + "type": "boolean" + }, "duplicated_project": { "description": "The copied project", "allOf": [ @@ -9868,7 +9807,8 @@ }, "value": { "description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.", - "type": "string" + "type": "string", + "maxLength": 20 } } }, @@ -9992,6 +9932,20 @@ } } }, + "models.ShareCounts": { + "type": "object", + "properties": { + "link_shares": { + "type": "integer" + }, + "team_shares": { + "type": "integer" + }, + "user_shares": { + "type": "integer" + } + } + }, "models.SharingType": { "type": "integer", "enum": [ @@ -10194,6 +10148,10 @@ } ] }, + "time_entries_count": { + "description": "Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`.", + "type": "integer" + }, "title": { "description": "The task text. This is what you'll see in the project.", "type": "string", @@ -10361,7 +10319,7 @@ "type": "integer" }, "relation_kind": { - "description": "The kind of the relation.", + "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.", "allOf": [ { "$ref": "#/definitions/models.RelationKind" @@ -10617,6 +10575,66 @@ } } }, + "models.UserExportStatus": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "expires": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "size": { + "type": "integer" + } + } + }, + "models.UserGeneralSettings": { + "type": "object", + "properties": { + "default_project_id": { + "type": "integer" + }, + "discoverable_by_email": { + "type": "boolean" + }, + "discoverable_by_name": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "extra_settings_links": { + "description": "Server/OpenID-provided; populated on read, ignored on write.", + "type": "object", + "additionalProperties": {} + }, + "frontend_settings": {}, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "overdue_tasks_reminders_enabled": { + "type": "boolean" + }, + "overdue_tasks_reminders_time": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "week_start": { + "type": "integer", + "maximum": 6, + "minimum": 0 + } + } + }, "models.UserWithPermission": { "type": "object", "properties": { @@ -10753,6 +10771,196 @@ } } }, + "shared.AdminUser": { + "type": "object", + "properties": { + "auth_provider": { + "type": "string" + }, + "bot_owner_id": { + "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", + "type": "integer" + }, + "created": { + "description": "A timestamp when this task was created. You cannot change this value.", + "type": "string" + }, + "email": { + "description": "The user's email address.", + "type": "string", + "maxLength": 250 + }, + "id": { + "description": "The unique, numeric id of this user.", + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "issuer": { + "type": "string" + }, + "name": { + "description": "The full name of the user.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/user.Status" + }, + "subject": { + "type": "string" + }, + "updated": { + "description": "A timestamp when this task was last updated. You cannot change this value.", + "type": "string" + }, + "username": { + "description": "The username of the user. Is always unique.", + "type": "string", + "maxLength": 250, + "minLength": 1 + } + } + }, + "shared.AuthInfo": { + "type": "object", + "properties": { + "ldap": { + "$ref": "#/definitions/shared.LdapAuthInfo" + }, + "local": { + "$ref": "#/definitions/shared.LocalAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/shared.OpenIDAuthInfo" + } + } + }, + "shared.LdapAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "shared.LegalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, + "shared.LocalAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "registration_enabled": { + "type": "boolean" + } + } + }, + "shared.OpenIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" + } + } + } + }, + "shared.VikunjaInfos": { + "type": "object", + "properties": { + "allow_icon_changes": { + "type": "boolean" + }, + "auth": { + "$ref": "#/definitions/shared.AuthInfo" + }, + "available_migrators": { + "type": "array", + "items": { + "type": "string" + } + }, + "caldav_enabled": { + "type": "boolean" + }, + "concurrent_writes": { + "description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.", + "type": "boolean" + }, + "demo_mode_enabled": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "enabled_background_providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled_pro_features": { + "type": "array", + "items": { + "$ref": "#/definitions/license.Feature" + } + }, + "frontend_url": { + "type": "string" + }, + "legal": { + "$ref": "#/definitions/shared.LegalInfo" + }, + "link_sharing_enabled": { + "type": "boolean" + }, + "max_file_size": { + "type": "string" + }, + "max_items_per_page": { + "type": "integer" + }, + "motd": { + "type": "string" + }, + "public_teams_enabled": { + "type": "boolean" + }, + "task_attachments_enabled": { + "type": "boolean" + }, + "task_comments_enabled": { + "type": "boolean" + }, + "totp_enabled": { + "type": "boolean" + }, + "user_deletion_enabled": { + "type": "boolean" + }, + "version": { + "type": "string" + }, + "webhooks_enabled": { + "type": "boolean" + } + } + }, "todoist.Migration": { "type": "object", "properties": { @@ -10933,6 +11141,18 @@ } } }, + "v1.LogoutResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "oidc_logout_url": { + "description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.", + "type": "string" + } + } + }, "v1.UserAvatarProvider": { "type": "object", "properties": { @@ -10950,23 +11170,6 @@ } } }, - "v1.UserExportStatus": { - "type": "object", - "properties": { - "created": { - "type": "string" - }, - "expires": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "size": { - "type": "integer" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { @@ -11014,59 +11217,6 @@ } } }, - "v1.UserSettings": { - "type": "object", - "properties": { - "default_project_id": { - "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.", - "type": "integer" - }, - "discoverable_by_email": { - "description": "If true, the user can be found when searching for their exact email.", - "type": "boolean" - }, - "discoverable_by_name": { - "description": "If true, this user can be found by their name or parts of it when searching for it.", - "type": "boolean" - }, - "email_reminders_enabled": { - "description": "If enabled, sends email reminders of tasks to the user.", - "type": "boolean" - }, - "extra_settings_links": { - "description": "Additional settings links as provided by openid", - "type": "object", - "additionalProperties": {} - }, - "frontend_settings": { - "description": "Additional settings only used by the frontend" - }, - "language": { - "description": "The user's language", - "type": "string" - }, - "name": { - "description": "The new name of the current user.", - "type": "string" - }, - "overdue_tasks_reminders_enabled": { - "description": "If enabled, the user will get an email for their overdue tasks each morning.", - "type": "boolean" - }, - "overdue_tasks_reminders_time": { - "description": "The time when the daily summary of overdue tasks will be sent via email.", - "type": "string" - }, - "timezone": { - "description": "The user's time zone. Used to send task reminders in the time zone of the user.", - "type": "string" - }, - "week_start": { - "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.", - "type": "integer" - } - } - }, "v1.UserWithSettings": { "type": "object", "properties": { @@ -11104,7 +11254,7 @@ "type": "string" }, "settings": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -11118,141 +11268,6 @@ } } }, - "v1.authInfo": { - "type": "object", - "properties": { - "ldap": { - "$ref": "#/definitions/v1.ldapAuthInfo" - }, - "local": { - "$ref": "#/definitions/v1.localAuthInfo" - }, - "openid_connect": { - "$ref": "#/definitions/v1.openIDAuthInfo" - } - } - }, - "v1.ldapAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "v1.legalInfo": { - "type": "object", - "properties": { - "imprint_url": { - "type": "string" - }, - "privacy_policy_url": { - "type": "string" - } - } - }, - "v1.localAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "registration_enabled": { - "type": "boolean" - } - } - }, - "v1.openIDAuthInfo": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "providers": { - "type": "array", - "items": { - "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" - } - } - } - }, - "v1.vikunjaInfos": { - "type": "object", - "properties": { - "allow_icon_changes": { - "type": "boolean" - }, - "auth": { - "$ref": "#/definitions/v1.authInfo" - }, - "available_migrators": { - "type": "array", - "items": { - "type": "string" - } - }, - "caldav_enabled": { - "type": "boolean" - }, - "demo_mode_enabled": { - "type": "boolean" - }, - "email_reminders_enabled": { - "type": "boolean" - }, - "enabled_background_providers": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled_pro_features": { - "type": "array", - "items": { - "$ref": "#/definitions/license.Feature" - } - }, - "frontend_url": { - "type": "string" - }, - "legal": { - "$ref": "#/definitions/v1.legalInfo" - }, - "link_sharing_enabled": { - "type": "boolean" - }, - "max_file_size": { - "type": "string" - }, - "max_items_per_page": { - "type": "integer" - }, - "motd": { - "type": "string" - }, - "public_teams_enabled": { - "type": "boolean" - }, - "task_attachments_enabled": { - "type": "boolean" - }, - "task_comments_enabled": { - "type": "boolean" - }, - "totp_enabled": { - "type": "boolean" - }, - "user_deletion_enabled": { - "type": "boolean" - }, - "version": { - "type": "string" - }, - "webhooks_enabled": { - "type": "boolean" - } - } - }, "web.HTTPError": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 1e1bbc818..775b2a024 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1,39 +1,5 @@ basePath: /api/v1 definitions: - admin.CreateUserBody: - properties: - email: - description: The user's email address - maxLength: 250 - type: string - is_admin: - description: Mark the new user as an instance admin. - type: boolean - language: - description: The language of the new user. Must be a valid IETF BCP 47 language - code and exist in Vikunja. - type: string - name: - description: The full name of the new user. Optional. - type: string - password: - description: The user's password in clear text. Only used when registering - the user. The maximum limi is 72 bytes, which may be less than 72 characters. - This is due to the limit in the bcrypt hashing algorithm used to store passwords - in Vikunja. - maxLength: 72 - minLength: 8 - type: string - skip_email_confirm: - description: Activate the new user immediately without email confirmation. - type: boolean - username: - description: The user's username. Cannot contain anything that looks like - an url or whitespaces. - maxLength: 250 - minLength: 3 - type: string - type: object admin.IsAdminPatch: properties: is_admin: @@ -41,35 +7,11 @@ definitions: silently demote otherwise. type: boolean type: object - admin.Overview: - properties: - license: - $ref: '#/definitions/license.Info' - projects: - type: integer - shares: - $ref: '#/definitions/admin.ShareCounts' - tasks: - type: integer - teams: - type: integer - users: - type: integer - type: object admin.OwnerPatch: properties: owner_id: type: integer type: object - admin.ShareCounts: - properties: - link_shares: - type: integer - team_shares: - type: integer - user_shares: - type: integer - type: object admin.StatusPatch: properties: status: @@ -78,47 +20,6 @@ definitions: description: Pointer to distinguish "omitted" from StatusActive; an empty body would silently re-enable otherwise. type: object - admin.User: - properties: - auth_provider: - type: string - bot_owner_id: - description: |- - BotOwnerID is the ID of the owning (human) user if this user is a bot. - A non-zero value means this user is a bot and cannot authenticate via password. - type: integer - created: - description: A timestamp when this task was created. You cannot change this - value. - type: string - email: - description: The user's email address. - maxLength: 250 - type: string - id: - description: The unique, numeric id of this user. - type: integer - is_admin: - type: boolean - issuer: - type: string - name: - description: The full name of the user. - type: string - status: - $ref: '#/definitions/user.Status' - subject: - type: string - updated: - description: A timestamp when this task was last updated. You cannot change - this value. - type: string - username: - description: The username of the user. Is always unique. - maxLength: 250 - minLength: 1 - type: string - type: object auth.Token: properties: token: @@ -423,6 +324,40 @@ definitions: values: $ref: '#/definitions/models.Task' type: object + models.CreateUserBody: + properties: + email: + description: The user's email address + maxLength: 250 + type: string + is_admin: + description: Mark the new user as an instance admin. + type: boolean + language: + description: The language of the new user. Must be a valid IETF BCP 47 language + code and exist in Vikunja. + type: string + name: + description: The full name of the new user. Optional. + type: string + password: + description: The user's password in clear text. Only used when registering + the user. The maximum limi is 72 bytes, which may be less than 72 characters. + This is due to the limit in the bcrypt hashing algorithm used to store passwords + in Vikunja. + maxLength: 72 + minLength: 8 + type: string + skip_email_confirm: + description: Activate the new user immediately without email confirmation. + type: boolean + username: + description: The user's username. Cannot contain anything that looks like + an url or whitespaces. + maxLength: 250 + minLength: 3 + type: string + type: object models.DatabaseNotifications: properties: created: @@ -545,6 +480,21 @@ definitions: description: A standard message. type: string type: object + models.Overview: + properties: + license: + $ref: '#/definitions/license.Info' + projects: + type: integer + shares: + $ref: '#/definitions/models.ShareCounts' + tasks: + type: integer + teams: + type: integer + users: + type: integer + type: object models.Permission: enum: - 0 @@ -627,6 +577,9 @@ definitions: type: object models.ProjectDuplicate: properties: + duplicate_shares: + description: Whether to copy the project's shares to the duplicate + type: boolean duplicated_project: allOf: - $ref: '#/definitions/models.Project' @@ -741,6 +694,7 @@ definitions: value: description: The actual reaction. This can be any valid utf character or text, up to a length of 20. + maxLength: 20 type: string type: object models.ReactionMap: @@ -834,6 +788,15 @@ definitions: this value. type: string type: object + models.ShareCounts: + properties: + link_shares: + type: integer + team_shares: + type: integer + user_shares: + type: integer + type: object models.SharingType: enum: - 0 @@ -1000,6 +963,10 @@ definitions: description: |- The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it. Will only returned when retrieving one task. + time_entries_count: + description: Time entry count of this task. Only present when fetching tasks + with the `expand` parameter set to `time_entries_count`. + type: integer title: description: The task text. This is what you'll see in the project. minLength: 1 @@ -1130,7 +1097,9 @@ definitions: relation_kind: allOf: - $ref: '#/definitions/models.RelationKind' - description: The kind of the relation. + description: |- + The kind of the relation. + The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it. task_id: description: The ID of the "base" task, the task which has a relation to another. type: integer @@ -1326,6 +1295,47 @@ definitions: this value. type: string type: object + models.UserExportStatus: + properties: + created: + type: string + expires: + type: string + id: + type: integer + size: + type: integer + type: object + models.UserGeneralSettings: + properties: + default_project_id: + type: integer + discoverable_by_email: + type: boolean + discoverable_by_name: + type: boolean + email_reminders_enabled: + type: boolean + extra_settings_links: + additionalProperties: {} + description: Server/OpenID-provided; populated on read, ignored on write. + type: object + frontend_settings: {} + language: + type: string + name: + type: string + overdue_tasks_reminders_enabled: + type: boolean + overdue_tasks_reminders_time: + type: string + timezone: + type: string + week_start: + maximum: 6 + minimum: 0 + type: integer + type: object models.UserWithPermission: properties: bot_owner_id: @@ -1437,6 +1447,141 @@ definitions: receiving a 412 with error code 1017. See GHSA-8jvc-mcx6-r4cg. type: string type: object + shared.AdminUser: + properties: + auth_provider: + type: string + bot_owner_id: + description: |- + BotOwnerID is the ID of the owning (human) user if this user is a bot. + A non-zero value means this user is a bot and cannot authenticate via password. + type: integer + created: + description: A timestamp when this task was created. You cannot change this + value. + type: string + email: + description: The user's email address. + maxLength: 250 + type: string + id: + description: The unique, numeric id of this user. + type: integer + is_admin: + type: boolean + issuer: + type: string + name: + description: The full name of the user. + type: string + status: + $ref: '#/definitions/user.Status' + subject: + type: string + updated: + description: A timestamp when this task was last updated. You cannot change + this value. + type: string + username: + description: The username of the user. Is always unique. + maxLength: 250 + minLength: 1 + type: string + type: object + shared.AuthInfo: + properties: + ldap: + $ref: '#/definitions/shared.LdapAuthInfo' + local: + $ref: '#/definitions/shared.LocalAuthInfo' + openid_connect: + $ref: '#/definitions/shared.OpenIDAuthInfo' + type: object + shared.LdapAuthInfo: + properties: + enabled: + type: boolean + type: object + shared.LegalInfo: + properties: + imprint_url: + type: string + privacy_policy_url: + type: string + type: object + shared.LocalAuthInfo: + properties: + enabled: + type: boolean + registration_enabled: + type: boolean + type: object + shared.OpenIDAuthInfo: + properties: + enabled: + type: boolean + providers: + items: + $ref: '#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider' + type: array + type: object + shared.VikunjaInfos: + properties: + allow_icon_changes: + type: boolean + auth: + $ref: '#/definitions/shared.AuthInfo' + available_migrators: + items: + type: string + type: array + caldav_enabled: + type: boolean + concurrent_writes: + description: ConcurrentWrites reports whether the configured database can + handle concurrent writes. It is false on SQLite, where overlapping write + transactions deadlock, so clients should serialize batched writes instead + of firing them in parallel. + type: boolean + demo_mode_enabled: + type: boolean + email_reminders_enabled: + type: boolean + enabled_background_providers: + items: + type: string + type: array + enabled_pro_features: + items: + $ref: '#/definitions/license.Feature' + type: array + frontend_url: + type: string + legal: + $ref: '#/definitions/shared.LegalInfo' + link_sharing_enabled: + type: boolean + max_file_size: + type: string + max_items_per_page: + type: integer + motd: + type: string + public_teams_enabled: + type: boolean + task_attachments_enabled: + type: boolean + task_comments_enabled: + type: boolean + totp_enabled: + type: boolean + user_deletion_enabled: + type: boolean + version: + type: string + webhooks_enabled: + type: boolean + type: object todoist.Migration: properties: code: @@ -1569,6 +1714,15 @@ definitions: password: type: string type: object + v1.LogoutResponse: + properties: + message: + type: string + oidc_logout_url: + description: RP-Initiated Logout URL the frontend redirects to. Empty for + non-OIDC sessions. + type: string + type: object v1.UserAvatarProvider: properties: avatar_provider: @@ -1583,17 +1737,6 @@ definitions: token: type: string type: object - v1.UserExportStatus: - properties: - created: - type: string - expires: - type: string - id: - type: integer - size: - type: integer - type: object v1.UserPassword: properties: new_password: @@ -1633,53 +1776,6 @@ definitions: minLength: 3 type: string type: object - v1.UserSettings: - properties: - default_project_id: - description: |- - If a task is created without a specified project this value should be used. Applies - to tasks made directly in API and from clients. - type: integer - discoverable_by_email: - description: If true, the user can be found when searching for their exact - email. - type: boolean - discoverable_by_name: - description: If true, this user can be found by their name or parts of it - when searching for it. - type: boolean - email_reminders_enabled: - description: If enabled, sends email reminders of tasks to the user. - type: boolean - extra_settings_links: - additionalProperties: {} - description: Additional settings links as provided by openid - type: object - frontend_settings: - description: Additional settings only used by the frontend - language: - description: The user's language - type: string - name: - description: The new name of the current user. - type: string - overdue_tasks_reminders_enabled: - description: If enabled, the user will get an email for their overdue tasks - each morning. - type: boolean - overdue_tasks_reminders_time: - description: The time when the daily summary of overdue tasks will be sent - via email. - type: string - timezone: - description: The user's time zone. Used to send task reminders in the time - zone of the user. - type: string - week_start: - description: The day when the week starts for this user. 0 = sunday, 1 = monday, - etc. - type: integer - type: object v1.UserWithSettings: properties: auth_provider: @@ -1710,7 +1806,7 @@ definitions: description: The full name of the user. type: string settings: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' updated: description: A timestamp when this task was last updated. You cannot change this value. @@ -1721,94 +1817,6 @@ definitions: minLength: 1 type: string type: object - v1.authInfo: - properties: - ldap: - $ref: '#/definitions/v1.ldapAuthInfo' - local: - $ref: '#/definitions/v1.localAuthInfo' - openid_connect: - $ref: '#/definitions/v1.openIDAuthInfo' - type: object - v1.ldapAuthInfo: - properties: - enabled: - type: boolean - type: object - v1.legalInfo: - properties: - imprint_url: - type: string - privacy_policy_url: - type: string - type: object - v1.localAuthInfo: - properties: - enabled: - type: boolean - registration_enabled: - type: boolean - type: object - v1.openIDAuthInfo: - properties: - enabled: - type: boolean - providers: - items: - $ref: '#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider' - type: array - type: object - v1.vikunjaInfos: - properties: - allow_icon_changes: - type: boolean - auth: - $ref: '#/definitions/v1.authInfo' - available_migrators: - items: - type: string - type: array - caldav_enabled: - type: boolean - demo_mode_enabled: - type: boolean - email_reminders_enabled: - type: boolean - enabled_background_providers: - items: - type: string - type: array - enabled_pro_features: - items: - $ref: '#/definitions/license.Feature' - type: array - frontend_url: - type: string - legal: - $ref: '#/definitions/v1.legalInfo' - link_sharing_enabled: - type: boolean - max_file_size: - type: string - max_items_per_page: - type: integer - motd: - type: string - public_teams_enabled: - type: boolean - task_attachments_enabled: - type: boolean - task_comments_enabled: - type: boolean - totp_enabled: - type: boolean - user_deletion_enabled: - type: boolean - version: - type: string - webhooks_enabled: - type: boolean - type: object web.HTTPError: properties: code: @@ -2011,7 +2019,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.Overview' + $ref: '#/definitions/models.Overview' "404": description: Not Found schema: @@ -2119,7 +2127,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' type: array "404": description: Not Found @@ -2141,14 +2149,14 @@ paths: name: body required: true schema: - $ref: '#/definitions/admin.CreateUserBody' + $ref: '#/definitions/models.CreateUserBody' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2216,7 +2224,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2253,7 +2261,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2531,7 +2539,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.vikunjaInfos' + $ref: '#/definitions/shared.VikunjaInfos' summary: Info tags: - service @@ -4713,9 +4721,10 @@ paths: consumes: - application/json 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. + 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. parameters: - description: The project ID to duplicate in: path @@ -6870,7 +6879,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.UserExportStatus' + $ref: '#/definitions/models.UserExportStatus' security: - JWTKeyAuth: [] summary: Get current user data export @@ -6945,13 +6954,15 @@ paths: /user/logout: post: description: Destroys the current session and clears the refresh token cookie. + For OpenID Connect sessions the response includes an `oidc_logout_url` the + client should redirect to so the provider session is ended too. produces: - application/json responses: "200": description: Successfully logged out. schema: - $ref: '#/definitions/models.Message' + $ref: '#/definitions/v1.LogoutResponse' summary: Logout tags: - auth @@ -7192,7 +7203,7 @@ paths: name: avatar required: true schema: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' produces: - application/json responses: diff --git a/pkg/user/events.go b/pkg/user/events.go index ff7866149..12b17a957 100644 --- a/pkg/user/events.go +++ b/pkg/user/events.go @@ -25,3 +25,34 @@ type CreatedEvent struct { func (t *CreatedEvent) Name() string { return "user.created" } + +// LoginSucceededEvent is fired after a user successfully authenticated, +// regardless of the auth provider (local, LDAP, OpenID). +type LoginSucceededEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginSucceededEvent +func (t *LoginSucceededEvent) Name() string { + return "user.login.succeeded" +} + +// LoginFailedEvent is fired for every failed password check of a known user. +type LoginFailedEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginFailedEvent +func (t *LoginFailedEvent) Name() string { + return "user.login.failed" +} + +// LogoutEvent is fired when a user destroys their session. +type LogoutEvent struct { + UserID int64 `json:"user_id"` +} + +// Name defines the name for LogoutEvent +func (t *LogoutEvent) Name() string { + return "user.logout" +} diff --git a/pkg/user/token.go b/pkg/user/token.go index 565270289..ee4e844da 100644 --- a/pkg/user/token.go +++ b/pkg/user/token.go @@ -41,12 +41,12 @@ const ( // Token is a token a user can use to do things like verify their email or resetting their password type Token struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this token."` UserID int64 `xorm:"not null" json:"-"` Token string `xorm:"varchar(450) not null index" json:"-"` - ClearTextToken string `xorm:"-" json:"token"` + ClearTextToken string `xorm:"-" json:"token" readOnly:"true" doc:"The token in clear text. Only returned once when the token is created; never on subsequent reads."` Kind TokenKind `xorm:"not null" json:"-"` - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this token was created. You cannot change this value."` } // TableName returns the real table name for user tokens diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 66abb813c..98c4327cb 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -17,8 +17,10 @@ package user import ( + "bytes" "fmt" "image" + "image/jpeg" "strconv" "time" @@ -37,11 +39,11 @@ import ( type TOTP struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"` UserID int64 `xorm:"bigint not null" json:"-"` - Secret string `xorm:"text not null" json:"secret"` + Secret string `xorm:"text not null" json:"secret" readOnly:"true" doc:"The shared secret used to generate passcodes, generated by the server on enrollment."` // The totp entry will only be enabled after the user verified they have a working totp setup. - Enabled bool `xorm:"null" json:"enabled"` + Enabled bool `xorm:"null" json:"enabled" readOnly:"true" doc:"Whether totp is fully activated. Set to true only after the user confirms a passcode."` // The totp url used to be able to enroll the user later - URL string `xorm:"text null" json:"url"` + URL string `xorm:"text null" json:"url" readOnly:"true" doc:"The otpauth:// url, generated by the server, used to enroll the user in an authenticator app."` } // TableName holds the table name for totp secrets @@ -198,6 +200,21 @@ func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err return key.Image(300, 300) } +// GetTOTPQrCodeAsJpegForUser renders the user's totp qr code to jpeg bytes, the +// wire format both API versions serve. +func GetTOTPQrCodeAsJpegForUser(s *xorm.Session, user *User) ([]byte, error) { + qrcode, err := GetTOTPQrCodeForUser(s, user) + if err != nil { + return nil, err + } + + buff := &bytes.Buffer{} + if err := jpeg.Encode(buff, qrcode, nil); err != nil { + return nil, err + } + return buff.Bytes(), nil +} + // HandleFailedTOTPAuth records a failed TOTP attempt and locks the account // after 10 consecutive failures. // diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 73e104682..26606c152 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -17,6 +17,8 @@ package user import ( + "context" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/notifications" "xorm.io/xorm" @@ -31,6 +33,17 @@ type EmailUpdate struct { Password string `json:"password"` } +// ChangeUserEmail verifies the user's password, then sets a new email address +// (kicking off confirmation when the mailer is enabled). Shared by the v1 and +// v2 email-update handlers; only HTTP input binding stays in the handlers. +func ChangeUserEmail(ctx context.Context, s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(ctx, s, &Login{Username: u.Username, Password: password}) + if err != nil { + return err + } + return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail}) +} + // UpdateEmail lets a user update their email address func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) { diff --git a/pkg/user/user.go b/pkg/user/user.go index 1aec85853..09fef2565 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -17,6 +17,7 @@ package user import ( + "context" "encoding/json" "errors" "fmt" @@ -27,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" @@ -362,8 +364,9 @@ func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User, return } -// CheckUserCredentials checks user credentials -func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { +// CheckUserCredentials checks user credentials. The context carries request +// metadata for the audit trail of failed attempts. +func CheckUserCredentials(ctx context.Context, s *xorm.Session, u *Login) (*User, error) { // Check if we have any credentials if u.Password == "" || u.Username == "" { return nil, ErrNoUsernamePassword{} @@ -390,7 +393,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { err = CheckUserPassword(user, u.Password) if err != nil { if IsErrWrongUsernameOrPassword(err) { - handleFailedPassword(user) + handleFailedPassword(ctx, user) } return user, err } @@ -410,7 +413,11 @@ func (u *User) IsLocalUser() bool { return u.Issuer == IssuerLocal } -func handleFailedPassword(user *User) { +func handleFailedPassword(ctx context.Context, user *User) { + if err := events.DispatchWithContext(ctx, &LoginFailedEvent{User: user}); err != nil { + log.Errorf("Could not dispatch login failed event: %s", err) + } + key := user.GetFailedPasswordAttemptsKey() err := keyvalue.IncrBy(key, 1) if err != nil { diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index 776a60b5d..38287c6e0 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -17,6 +17,7 @@ package user import ( + "context" "testing" "code.vikunja.io/api/pkg/db" @@ -357,7 +358,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345678"}) require.NoError(t, err) }) t.Run("unverified email", func(t *testing.T) { @@ -365,7 +366,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user5", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user5", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrEmailNotConfirmed(err)) }) @@ -374,7 +375,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -383,7 +384,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "dfstestuu", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "dfstestuu", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -392,7 +393,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -401,7 +402,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -410,7 +411,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1@example.com", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1@example.com", Password: "12345678"}) require.NoError(t, err) }) t.Run("disabled user", func(t *testing.T) { @@ -419,7 +420,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user17 is disabled (status=2), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user17", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user17", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountDisabled(err)) }) @@ -429,7 +430,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user18 is locked (status=3), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user18", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user18", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountLocked(err)) }) diff --git a/pkg/user/users_project.go b/pkg/user/users_project.go index ab94a4bdf..c08cc23c2 100644 --- a/pkg/user/users_project.go +++ b/pkg/user/users_project.go @@ -34,6 +34,20 @@ type ProjectUserOpts struct { MatchFuzzily bool } +// SearchUsers performs the global user search shared by both API versions: +// it lists users matching the search string and obfuscates their email +// addresses before returning. +func SearchUsers(s *xorm.Session, search string, currentUser *User) (users []*User, err error) { + users, err = ListUsers(s, search, currentUser, nil) + if err != nil { + return nil, err + } + for i := range users { + users[i].Email = "" + } + return users, nil +} + // ListUsers returns a list with all users, filtered by an optional search string func ListUsers(s *xorm.Session, search string, currentUser *User, opts *ProjectUserOpts) (users []*User, err error) { if opts == nil { diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go new file mode 100644 index 000000000..461fe2780 --- /dev/null +++ b/pkg/web/files/file.go @@ -0,0 +1,61 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package files + +import ( + "io" + "mime" + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/files" +) + +// WriteFileDownload streams a loaded file (its .File reader must be open) to the +// response as an attachment download: http.ServeContent for seekable local files +// (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does +// not close the reader; the caller owns it. +func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) { + // Downloads must never be cached. no-cache overrides the global no-store + // directive so revalidation (If-Modified-Since) still works. + w.Header().Set("Cache-Control", "no-cache") + + mimeToReturn := f.Mime + if mimeToReturn == "" { + mimeToReturn = "application/octet-stream" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": f.Name})) + w.Header().Set("Content-Type", mimeToReturn) + w.Header().Set("Content-Length", strconv.FormatUint(f.Size, 10)) + w.Header().Set("Last-Modified", f.Created.UTC().Format(http.TimeFormat)) + + // Local files are *os.File (seekable), so ServeContent gives Range + + // If-Modified-Since for free; s3 (and the in-memory test storage) return a + // non-seekable reader, so check If-Modified-Since manually and io.Copy. + if seeker, ok := f.File.(io.ReadSeeker); ok { + http.ServeContent(w, r, f.Name, f.Created, seeker) + return + } + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !f.Created.UTC().After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + _, _ = io.Copy(w, f.File) +} diff --git a/pkg/web/files/project_background.go b/pkg/web/files/project_background.go new file mode 100644 index 000000000..aeda725a7 --- /dev/null +++ b/pkg/web/files/project_background.go @@ -0,0 +1,55 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package files + +import ( + "io" + "net/http" + "os" + + "code.vikunja.io/api/pkg/files" +) + +// WriteProjectBackground streams a project's background file (its .File reader must be +// open) to the response, shared by the v1 and v2 background handlers. It does not close +// the reader; the caller owns it. +// +// The wire shape differs from WriteFileDownload on purpose and must stay byte-identical +// to v1: backgrounds are always served as image/jpg (no Content-Disposition, no +// Content-Length), with a cache-revalidation Last-Modified from the storage modtime +// rather than the file's DB Created timestamp. +func WriteProjectBackground(w http.ResponseWriter, r *http.Request, bgFile *files.File, stat os.FileInfo) { + // Override the global no-store directive so browsers can cache background images. + // no-cache allows caching but requires revalidation via If-Modified-Since. + w.Header().Set("Cache-Control", "no-cache") + + if stat != nil { + modTime := stat.ModTime().UTC() + w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat)) + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + } + + w.Header().Set("Content-Type", "image/jpg") + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, bgFile.File) +} diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go new file mode 100644 index 000000000..55945fe23 --- /dev/null +++ b/pkg/web/files/task_attachment.go @@ -0,0 +1,79 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package files holds the HTTP-layer glue for serving task attachments — +// the upload-result DTOs and the download response writer — shared by the +// v1 and v2 handlers. The domain logic stays in pkg/models; this package +// only translates it to and from the wire. +package files + +import ( + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web" +) + +// AttachmentUploadError is a per-file upload failure. +type AttachmentUploadError struct { + Code int `json:"code,omitempty" doc:"Vikunja numeric error code, when the failure carries one."` + Message string `json:"message" doc:"A human-readable description of why this file failed."` +} + +// AttachmentUploadResult is the outcome of an attachment upload: files are +// processed independently, so a per-file failure lands in Errors while the +// rest still succeed. +type AttachmentUploadResult struct { + Errors []AttachmentUploadError `json:"errors" doc:"Per-file failures. A file that fails here does not fail the whole request; the others still upload."` + Success []*models.TaskAttachment `json:"success" doc:"The attachments that were created successfully."` +} + +// BuildUploadResult turns the domain function's plain return values into the +// wire DTO, mapping each failure to its numeric code when it carries one. +func BuildUploadResult(success []*models.TaskAttachment, failures []error) *AttachmentUploadResult { + r := &AttachmentUploadResult{Success: success} + for _, err := range failures { + r.Errors = append(r.Errors, toAttachmentUploadError(err)) + } + return r +} + +func toAttachmentUploadError(err error) AttachmentUploadError { + if httpErr, ok := err.(web.HTTPErrorProcessor); ok { + details := httpErr.HTTPError() + return AttachmentUploadError{Code: details.Code, Message: details.Message} + } + return AttachmentUploadError{Message: err.Error()} +} + +// WriteAttachmentDownload streams the attachment (or its inline image preview) to +// the response and closes the file reader. The non-preview path delegates to +// WriteFileDownload, which sets Cache-Control: no-cache; the preview branch returns +// early, so it sets the same header itself. +func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { + defer func() { _ = ta.File.File.Close() }() + + if preview != nil { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", strconv.Itoa(len(preview))) + _, _ = w.Write(preview) + return + } + + WriteFileDownload(w, r, ta.File) +} diff --git a/pkg/web/files/task_attachment_test.go b/pkg/web/files/task_attachment_test.go new file mode 100644 index 000000000..5b282b6b6 --- /dev/null +++ b/pkg/web/files/task_attachment_test.go @@ -0,0 +1,56 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package files + +import ( + "errors" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" +) + +func TestBuildUploadResult(t *testing.T) { + t.Run("maps a domain error to its numeric code", func(t *testing.T) { + // ErrTaskAttachmentIsTooLarge is an HTTPErrorProcessor, so its Code must surface. + r := BuildUploadResult(nil, []error{models.ErrTaskAttachmentIsTooLarge{Size: 99}}) + assert.Empty(t, r.Success) + if assert.Len(t, r.Errors, 1) { + assert.Equal(t, models.ErrCodeTaskAttachmentIsTooLarge, r.Errors[0].Code) + assert.NotEmpty(t, r.Errors[0].Message) + } + }) + + t.Run("plain error has no code, just the message", func(t *testing.T) { + r := BuildUploadResult(nil, []error{errors.New("boom")}) + if assert.Len(t, r.Errors, 1) { + assert.Zero(t, r.Errors[0].Code) + assert.Equal(t, "boom", r.Errors[0].Message) + } + }) + + t.Run("preserves success and failure order", func(t *testing.T) { + success := []*models.TaskAttachment{{ID: 1}, {ID: 2}} + r := BuildUploadResult(success, []error{errors.New("first"), errors.New("second")}) + assert.Equal(t, success, r.Success) + if assert.Len(t, r.Errors, 2) { + assert.Equal(t, "first", r.Errors[0].Message) + assert.Equal(t, "second", r.Errors[1].Message) + } + }) +} diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go index 01474b874..25c91c069 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -28,7 +28,7 @@ import ( // DoCreate runs the permission check + model Create + commit pipeline for a // CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma. // Caller is responsible for body/path binding and validation before calling. -func DoCreate(_ context.Context, obj CObject, a web.Auth) error { +func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -60,7 +60,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -68,7 +68,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error { // CObject. obj should have its identifying fields set before call. On success, // obj is fully populated. maxPermission is exposed via the x-max-permission // header in the Echo wrapper; Huma wrapper may ignore it. -func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { +func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { s := db.NewSession() defer func() { if cerr := s.Close(); cerr != nil { @@ -100,7 +100,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e return 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return maxPermission, nil } @@ -108,7 +108,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e // scoping context (e.g., TaskID on LabelTask). Returns the result slice/ // interface, the result count, and total count. Pagination header math and // nil-slice normalization remain the caller's responsibility. -func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { +func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { s := db.NewSession() defer func() { if cerr := s.Close(); cerr != nil { @@ -128,14 +128,14 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, return nil, 0, 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return result, resultCount, total, nil } // DoUpdate runs the permission check + model Update + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for body/path binding // and validation before calling. -func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { +func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -167,14 +167,14 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } // DoDelete runs the permission check + model Delete + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for path binding before // calling. -func DoDelete(_ context.Context, obj CObject, a web.Auth) error { +func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -206,6 +206,6 @@ func DoDelete(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } diff --git a/pkg/websocket/connection.go b/pkg/websocket/connection.go index 0438b6fef..a2fb54d47 100644 --- a/pkg/websocket/connection.go +++ b/pkg/websocket/connection.go @@ -264,6 +264,9 @@ func (c *Connection) WriteLoop(ctx context.Context, cancel context.CancelFunc) { // validEvents is the set of event names clients are allowed to subscribe to. var validEvents = map[string]bool{ "notification.created": true, + "timer.created": true, + "timer.updated": true, + "timer.deleted": true, } func isValidEvent(event string) bool { diff --git a/pkg/websocket/connection_test.go b/pkg/websocket/connection_test.go index f5bccdac2..d05ab5c1e 100644 --- a/pkg/websocket/connection_test.go +++ b/pkg/websocket/connection_test.go @@ -80,6 +80,20 @@ func TestConnectionRejectsInvalidEvent(t *testing.T) { assert.False(t, conn.IsSubscribed("notifications")) } +func TestConnectionAllowsTimerEvents(t *testing.T) { + conn := &Connection{ + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + for _, event := range []string{"timer.created", "timer.updated"} { + conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: event}) + assert.True(t, conn.IsSubscribed(event), "client must be able to subscribe to %s", event) + } +} + func TestConnectionRejectsActionsBeforeAuth(t *testing.T) { conn := &Connection{ userID: 0, // not authenticated diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go index 3250d1c45..e46759a71 100644 --- a/pkg/websocket/listener.go +++ b/pkg/websocket/listener.go @@ -21,7 +21,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/notifications" "github.com/ThreeDotsLabs/watermill/message" @@ -67,10 +69,50 @@ func (n *NotificationListener) Handle(msg *message.Message) error { return nil } +// TimeEntryListener pushes a user's own timer changes to their WebSocket +// connections. wsEvent is "timer.created", "timer.updated" or "timer.deleted"; +// the payload is the full entry, so the running-elsewhere badge reads end_time +// to know whether a timer is active (and the id to drop a deleted one). Not +// emitted on unlicensed instances. +type TimeEntryListener struct { + wsEvent string +} + +func (l *TimeEntryListener) Name() string { return "websocket.push." + l.wsEvent } + +func (l *TimeEntryListener) Handle(msg *message.Message) error { + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + return nil + } + + // All TimeEntry events share the {time_entry, doer} shape; only the entry is needed. + var event struct { + TimeEntry *models.TimeEntry `json:"time_entry"` + } + if err := json.Unmarshal(msg.Payload, &event); err != nil { + return err + } + if event.TimeEntry == nil { + return nil + } + + hub := GetHub() + if hub == nil { + log.Warningf("WebSocket: hub not initialized, skipping timer push") + return nil + } + + hub.PublishForUser(event.TimeEntry.UserID, l.wsEvent, event.TimeEntry) + return nil +} + // RegisterListeners registers WebSocket event listeners. func RegisterListeners() { events.RegisterListener( (¬ifications.NotificationCreatedEvent{}).Name(), &NotificationListener{}, ) + events.RegisterListener((&models.TimeEntryCreatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.created"}) + events.RegisterListener((&models.TimeEntryUpdatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.updated"}) + events.RegisterListener((&models.TimeEntryDeletedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.deleted"}) } diff --git a/pkg/websocket/main_test.go b/pkg/websocket/main_test.go index 7740dc0e0..21243069b 100644 --- a/pkg/websocket/main_test.go +++ b/pkg/websocket/main_test.go @@ -20,10 +20,14 @@ import ( "os" "testing" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" ) func TestMain(m *testing.M) { log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue os.Exit(m.Run()) } diff --git a/pkg/websocket/time_entry_listener_test.go b/pkg/websocket/time_entry_listener_test.go new file mode 100644 index 000000000..8f67df8b9 --- /dev/null +++ b/pkg/websocket/time_entry_listener_test.go @@ -0,0 +1,113 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "testing" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timerConn(userID int64) *Connection { + return &Connection{ + userID: userID, + subscriptions: map[string]bool{"timer.created": true, "timer.updated": true, "timer.deleted": true}, + send: make(chan OutgoingMessage, 16), + } +} + +func TestTimeEntryListener(t *testing.T) { + t.Run("a create pushes timer.created with the entry to its owner", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryCreatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.created"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.created", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok, "payload must be the time entry itself") + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("an update pushes timer.updated", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + require.Len(t, conn.send, 1) + assert.Equal(t, "timer.updated", (<-conn.send).Event) + }) + + t.Run("a delete pushes timer.deleted so other tabs drop it", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryDeletedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.deleted"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.deleted", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok) + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("does not push when the feature is disabled", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.ResetForTests() // free mode + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, conn.send) + }) + + t.Run("only pushes to the entry owner", func(t *testing.T) { + InitHub() + other := timerConn(2) + GetHub().Register(other) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, other.send, "a different user must not receive the timer update") + }) +} diff --git a/pkg/webtests/huma_admin_actions_test.go b/pkg/webtests/huma_admin_actions_test.go new file mode 100644 index 000000000..806036a32 --- /dev/null +++ b/pkg/webtests/huma_admin_actions_test.go @@ -0,0 +1,387 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Gate behaviour (404 on non-admin/unlicensed, 401 unauthenticated) is shared by +// every /api/v2/admin route; covered once here against the overview endpoint. +func TestHumaAdminOverview(t *testing.T) { + t.Run("non-admin user gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin") + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", u, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("admin without the feature gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("unauthenticated caller gets 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", nil, "") + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("admin with the feature sees the overview", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "") + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + body := res.Body.String() + assert.Contains(t, body, `"users"`) + assert.Contains(t, body, `"projects"`) + assert.Contains(t, body, `"tasks"`) + assert.Contains(t, body, `"shares"`) + assert.Contains(t, body, `"license"`) + assert.Contains(t, body, `"licensed":true`) + assert.Contains(t, body, `"instance_id"`) + }) +} + +func TestHumaAdminCreateUser(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + // Admin endpoint must bypass the public-registration toggle. + prev := config.ServiceEnableRegistration.GetBool() + config.ServiceEnableRegistration.Set(false) + defer config.ServiceEnableRegistration.Set(prev) + + admin := promoteToAdmin(t, 1) + + t.Run("creates a plain user and returns 201", func(t *testing.T) { + body := `{"username":"v2adm-create-1","password":"averyl0ngpassword","email":"v2adm-create-1@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + assert.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + assert.Contains(t, res.Body.String(), `"username":"v2adm-create-1"`) + }) + + t.Run("creates an is_admin user", func(t *testing.T) { + body := `{"username":"v2adm-create-2","password":"averyl0ngpassword","email":"v2adm-create-2@example.com","is_admin":true}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-2") + require.NoError(t, err) + assert.True(t, u.IsAdmin, "new user should have been promoted") + }) + + t.Run("skip_email_confirm forces Status=Active", func(t *testing.T) { + body := `{"username":"v2adm-create-3","password":"averyl0ngpassword","email":"v2adm-create-3@example.com","skip_email_confirm":true}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-3") + require.NoError(t, err) + assert.Equal(t, user.StatusActive, u.Status) + }) + + t.Run("persists the name field", func(t *testing.T) { + body := `{"username":"v2adm-create-4","password":"averyl0ngpassword","email":"v2adm-create-4@example.com","name":"Adm Create"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-4") + require.NoError(t, err) + assert.Equal(t, "Adm Create", u.Name) + }) + + t.Run("rejects an invalid body with 422", func(t *testing.T) { + // Password below the 8-char minimum fails govalidator before the create. + body := `{"username":"v2adm-invalid","password":"short","email":"v2adm-invalid@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + assert.Equal(t, http.StatusUnprocessableEntity, res.Code, res.Body.String()) + }) + + t.Run("non-admin caller gets 404", func(t *testing.T) { + s := db.NewSession() + u2, err := user.GetUserByID(s, 2) + require.NoError(t, err) + require.False(t, u2.IsAdmin, "fixture precondition: user2 is not an admin") + s.Close() + + body := `{"username":"v2nonadmin","password":"averyl0ngpassword","email":"v2nonadmin@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", u2, body) + assert.Equal(t, http.StatusNotFound, res.Code) + }) +} + +func TestHumaAdminPatchAdmin(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("promote a non-admin user", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.True(t, u.IsAdmin) + }) + + t.Run("demote when another admin exists is allowed", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":false}`) + assert.Equal(t, http.StatusOK, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.False(t, u.IsAdmin) + }) + + t.Run("last-admin guard refuses demotion with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/admin", admin, `{"is_admin":false}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + assert.True(t, u.IsAdmin, "last admin must remain admin after refused demotion") + }) + + t.Run("unknown user returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/9999999/admin", admin, `{"is_admin":true}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("omitted is_admin is rejected rather than demoting", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`) + require.Equal(t, http.StatusOK, res.Code) + + res = adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.True(t, u.IsAdmin, "omitted is_admin must not silently demote") + }) +} + +func TestHumaAdminPatchStatus(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":2}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + // GetUserByID refuses disabled accounts, so assert against the raw row. + s := db.NewSession() + defer s.Close() + var row struct { + Status int `xorm:"status"` + } + _, err = s.Table("users").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, 2, row.Status) + + t.Run("last-admin guard refuses self-disable with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/status", admin, `{"status":2}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + var row struct { + Status int `xorm:"status"` + } + _, err := s.Table("users").Where("id = ?", 1).Get(&row) + require.NoError(t, err) + assert.Equal(t, int(user.StatusActive), row.Status, "last admin must stay active after refused disable") + }) + + t.Run("rejects invalid status value with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":99}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + assert.Contains(t, res.Body.String(), "invalid status") + }) + + t.Run("omitted status is rejected rather than reactivating", func(t *testing.T) { + // User 2 was disabled above; an empty body must leave that intact. + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + var row struct { + Status int `xorm:"status"` + } + _, err := s.Table("users").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, int(user.StatusDisabled), row.Status, "omitted status must not silently reactivate") + }) +} + +func TestHumaAdminDeleteUser(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("mode=now deletes a regular user immediately with 204", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/15?mode=now", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + _, err := user.GetUserByID(s, 15) + assert.Error(t, err, "deleted user must no longer be fetchable") + }) + + t.Run("mode=scheduled keeps the user row", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/16?mode=scheduled", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + u := &user.User{ID: 16} + has, err := s.Get(u) + require.NoError(t, err) + assert.True(t, has, "scheduled deletion must not remove the user row") + }) + + t.Run("default (no mode) is scheduled", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/2", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + u := &user.User{ID: 2} + has, err := s.Get(u) + require.NoError(t, err) + assert.True(t, has, "default mode must not remove the user row") + }) + + t.Run("rejects invalid mode with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/3?mode=bogus", admin, "") + assert.Equal(t, http.StatusBadRequest, res.Code) + }) + + t.Run("mode=now last-admin guard refuses self-delete with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/1?mode=now", admin, "") + assert.Equal(t, http.StatusBadRequest, res.Code) + }) + + t.Run("unknown user returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/9999999?mode=now", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) +} + +func TestHumaAdminReassignProjectOwner(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("updates owner_id", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":2}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + var row struct { + OwnerID int64 `xorm:"owner_id"` + } + _, err := s.Table("projects").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, int64(2), row.OwnerID) + }) + + t.Run("rejects nonexistent owner with 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":99999}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("nonexistent project returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/99999/owner", admin, `{"owner_id":1}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("rejects disabled user as new owner with 412", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":17}`) + assert.Equal(t, http.StatusPreconditionFailed, res.Code) + }) + + t.Run("rejects locked user as new owner with 412", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":18}`) + assert.Equal(t, http.StatusPreconditionFailed, res.Code) + }) + + t.Run("rejects deletion-scheduled user as new owner with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":20}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + }) +} diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go new file mode 100644 index 000000000..48931effa --- /dev/null +++ b/pkg/webtests/huma_auth_login_test.go @@ -0,0 +1,196 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// refreshCookie returns the Set-Cookie value for the refresh-token cookie, or "" +// if the response set no such cookie. +func refreshCookie(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == auth.RefreshTokenCookieName { + return c + } + } + return nil +} + +// TestHumaLogin ports the v1 login coverage to /api/v2: it asserts the token +// response, the HttpOnly refresh cookie, the no-store header, and the credential +// and TOTP gates. +func TestHumaLogin(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + login := func(body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, "/api/v2/login", body, "", "") + } + + t.Run("normal login", func(t *testing.T) { + rec := login(`{"username":"user1","password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "login must set the refresh-token cookie") + assert.NotEmpty(t, cookie.Value) + assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly") + }) + + t.Run("wrong password", func(t *testing.T) { + rec := login(`{"username":"user1","password":"wrong"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec)) + assert.Nil(t, refreshCookie(rec), "a failed login must not set a refresh cookie") + }) + + t.Run("nonexistent user", func(t *testing.T) { + rec := login(`{"username":"userWhichDoesNotExist","password":"12345678"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec)) + }) + + t.Run("unconfirmed email", func(t *testing.T) { + rec := login(`{"username":"user5","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeEmailNotConfirmed, problemCode(t, rec)) + }) + + t.Run("disabled account", func(t *testing.T) { + rec := login(`{"username":"user17","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeAccountDisabled, problemCode(t, rec)) + }) + + t.Run("locked account", func(t *testing.T) { + rec := login(`{"username":"user18","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeAccountLocked, problemCode(t, rec)) + }) + + t.Run("TOTP required but missing", func(t *testing.T) { + rec := login(`{"username":"user10","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec)) + }) + + t.Run("TOTP wrong", func(t *testing.T) { + rec := login(`{"username":"user10","password":"12345678","totp_passcode":"000000"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec)) + }) + + t.Run("TOTP correct", func(t *testing.T) { + code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now()) + require.NoError(t, err) + rec := login(`{"username":"user10","password":"12345678","totp_passcode":"` + code + `"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.NotNil(t, refreshCookie(rec)) + }) +} + +// TestHumaLogout proves the v2 logout deletes the session server-side and clears +// the refresh-token cookie. +func TestHumaLogout(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + // Create a session so logout has something to delete, then mint a JWT whose + // sid claim points at it. + s := db.NewSession() + session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil) + require.NoError(t, err) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + token, err := auth.NewUserJWTAuthtoken(&testuser1, session.ID) + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/logout", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "Successfully logged out.") + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "logout must clear the refresh cookie") + assert.Empty(t, cookie.Value, "cleared cookie has no value") + assert.Negative(t, cookie.MaxAge, "cleared cookie is expired") + + // The session must be gone. + check := db.NewSession() + defer check.Close() + exists, err := check.Where("id = ?", session.ID).Exist(&models.Session{}) + require.NoError(t, err) + assert.False(t, exists, "logout must delete the session") +} + +// TestHumaLoginUnauthenticated proves login needs no token (it is a public op). +func TestHumaLoginUnauthenticated(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/login", `{"username":"user1","password":"12345678"}`, "", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) +} + +// TestHumaOpenIDGating proves the OIDC callback route only exists when OpenID is +// enabled, mirroring the registrar gate. +func TestHumaOpenIDGating(t *testing.T) { + body := `{"code":"abc","redirect_url":"https://example.com"}` + + t.Run("disabled returns 404", func(t *testing.T) { + config.AuthOpenIDEnabled.Set(false) + + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/test/callback", body, "", "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("enabled does not require auth", func(t *testing.T) { + config.AuthOpenIDEnabled.Set(true) + defer config.AuthOpenIDEnabled.Set(false) + + e, err := setupTestEnv() + require.NoError(t, err) + + // No provider is configured, so the call fails downstream — but it must + // not 404 as an unknown route nor 401 for missing auth, which proves the + // public route is registered. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/doesnotexist/callback", body, "", "") + assert.NotEqual(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusUnauthorized, rec.Code) + }) +} diff --git a/pkg/webtests/huma_auth_refresh_test.go b/pkg/webtests/huma_auth_refresh_test.go new file mode 100644 index 000000000..48fad31c6 --- /dev/null +++ b/pkg/webtests/huma_auth_refresh_test.go @@ -0,0 +1,92 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// refreshRequest posts to the v2 refresh endpoint with the given refresh-token +// cookie value (empty value omits the cookie entirely), driving the full +// echo+Huma stack so cookie reading and Set-Cookie writing are exercised. +func refreshRequest(e *echo.Echo, refreshToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v2/user/token/refresh", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + if refreshToken != "" { + req.AddCookie(&http.Cookie{Name: auth.RefreshTokenCookieName, Value: refreshToken}) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaRefreshToken ports the v1 refresh-token coverage to /api/v2: a valid +// cookie yields a new JWT and a rotated HttpOnly cookie, the old token then stops +// working, and missing/invalid cookies map to the same 401 v1 returns. +func TestHumaRefreshToken(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("valid refresh token", func(t *testing.T) { + rec := refreshRequest(e, "testtoken_session1") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "refresh must set a new refresh-token cookie") + assert.NotEmpty(t, cookie.Value) + assert.NotEqual(t, "testtoken_session1", cookie.Value, "refresh token must be rotated") + assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly") + }) + + t.Run("rotation invalidates the old token", func(t *testing.T) { + // session2 is a separate session so this case does not depend on the + // one above. The first refresh succeeds and rotates the token. + first := refreshRequest(e, "testtoken_session2") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + newCookie := refreshCookie(first) + require.NotNil(t, newCookie) + + // Replaying the now-rotated token must fail. + replay := refreshRequest(e, "testtoken_session2") + assert.Equal(t, http.StatusUnauthorized, replay.Code) + + // The freshly rotated token still works. + next := refreshRequest(e, newCookie.Value) + assert.Equal(t, http.StatusOK, next.Code, next.Body.String()) + }) + + t.Run("missing cookie", func(t *testing.T) { + rec := refreshRequest(e, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("invalid cookie", func(t *testing.T) { + rec := refreshRequest(e, "garbage") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} diff --git a/pkg/webtests/huma_auth_test.go b/pkg/webtests/huma_auth_test.go new file mode 100644 index 000000000..404f89f00 --- /dev/null +++ b/pkg/webtests/huma_auth_test.go @@ -0,0 +1,293 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaAuthPublic ports the v1 coverage of the public local-account flows +// (register, password reset, email confirm) to /api/v2. These endpoints opt out +// of the global auth, so requests carry no token. +func TestHumaAuthPublic(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(path, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, path, body, "", "") + } + + t.Run("Register", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"newhumauser","password":"12345678","email":"newhuma@example.com"}`) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"username":"newhumauser"`) + }) + t.Run("already existing username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"user1","password":"12345678","email":"x@example.com"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + t.Run("empty username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"","password":"12345678","email":"x@example.com"}`) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest) + }) + }) + + t.Run("Request password reset token", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1@example.com"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "Token was sent.") + }) + t.Run("no user with that email", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1000@example.com"}`) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + }) + + t.Run("Reset password", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"passwordresettesttoken","new_password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"invalidtoken","new_password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) + + t.Run("Confirm email", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The email was confirmed successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"invalidToken"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) +} + +// TestHumaRegisterDisabled proves the registration endpoint 404s when +// registration is disabled, mirroring v1. +func TestHumaRegisterDisabled(t *testing.T) { + config.ServiceEnableRegistration.Set(false) + defer config.ServiceEnableRegistration.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/register", + `{"username":"nope","password":"12345678","email":"nope@example.com"}`, "", "") + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHumaLinkShareAuth ports the v1 link-share auth coverage to /api/v2. +func TestHumaLinkShareAuth(t *testing.T) { + config.ServiceEnableLinkSharing.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(share, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, "/api/v2/shares/"+share+"/auth", body, "", "") + } + + t.Run("without password", func(t *testing.T) { + rec := post("test", ``) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + }) + t.Run("with password, correct", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("with password, missing", func(t *testing.T) { + rec := post("testWithPassword", ``) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordRequired, problemCode(t, rec)) + }) + t.Run("with password, wrong", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"wrong"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordInvalid, problemCode(t, rec)) + }) +} + +// TestHumaTokenMeta ports the token-introspection and link-share renew +// endpoints to /api/v2. +func TestHumaTokenMeta(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + userToken := humaTokenFor(t, &testuser1) + + t.Run("token test (GET) returns ok", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check (POST) returns 200, not 418", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + t.Run("routes lists token routes", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/routes", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var routes map[string]map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &routes)) + assert.Contains(t, routes, "tasks") + }) + + t.Run("renew link-share token", func(t *testing.T) { + share := &models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + } + shareToken, err := auth.NewLinkShareJWTAuthtoken(share) + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", shareToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("renew rejects user token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", userToken, "") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +// TestHumaOAuth ports the OAuth 2.0 token and authorize flows to /api/v2 and +// exercises both the JSON and the spec-compliant form-urlencoded encodings of +// the token endpoint. +func TestHumaOAuth(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("authorize requires authentication", func(t *testing.T) { + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc", "S256", "s") + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("full code flow with PKCE (JSON token request)", func(t *testing.T) { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "xyz") + + body, _ := json.Marshal(map[string]string{ //nolint:errchkjson + "grant_type": "authorization_code", + "code": code, + "client_id": "vikunja", + "redirect_uri": "vikunja-flutter://callback", + "code_verifier": verifier, + }) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", string(body), "", "application/json") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.Equal(t, "bearer", resp.TokenType) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("full code flow with PKCE (form-urlencoded token request)", func(t *testing.T) { + verifier := "form-encoded-flow-verifier" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "") + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {"vikunja"}, + "redirect_uri": {"vikunja-flutter://callback"}, + "code_verifier": {verifier}, + } + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("invalid grant type", func(t *testing.T) { + form := url.Values{"grant_type": {"password"}, "client_id": {"vikunja"}} + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// authorizeV2 runs the v2 authorize step for testuser1 and returns the code. +func authorizeV2(t *testing.T, e *echo.Echo, challenge, state string) string { + t.Helper() + token := humaTokenFor(t, &testuser1) + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", challenge, "S256", state) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.AuthorizeResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotEmpty(t, resp.Code) + return resp.Code +} + +// problemCode pulls the Vikunja numeric error code out of an RFC 9457 body. +func problemCode(t *testing.T, rec *httptest.ResponseRecorder) int { + t.Helper() + var body struct { + Code int `json:"code"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Code +} diff --git a/pkg/webtests/huma_background_download_test.go b/pkg/webtests/huma_background_download_test.go new file mode 100644 index 000000000..e4c542a26 --- /dev/null +++ b/pkg/webtests/huma_background_download_test.go @@ -0,0 +1,162 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// getBackgroundRequest issues a GET against the background download route with an +// optional If-Modified-Since header (humaRequest can't set arbitrary headers). +func getBackgroundRequest(t *testing.T, e *echo.Echo, project, token, ifModifiedSince string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/"+project+"/background", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if ifModifiedSince != "" { + req.Header.Set("If-Modified-Since", ifModifiedSince) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaProjectBackgroundDownload covers GET /projects/{project}/background. The +// fixture file row (project 35, background_file_id 1) carries no bytes, so the happy +// path uploads a real background first (the "upload-then-download" pattern) before +// fetching it back. +func TestHumaProjectBackgroundDownload(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner uploads then downloads the background", func(t *testing.T) { + // testuser1 owns project 1, which starts without a background. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "image/jpg", rec.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) + assert.NotEmpty(t, rec.Body.Bytes(), "the download must return the stored bytes") + }) + + t.Run("If-Modified-Since returns 304", func(t *testing.T) { + // The in-memory test storage reports a zero modtime, so any valid + // If-Modified-Since is not-before it and yields a 304. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "Wed, 21 Oct 2015 07:28:00 GMT") + assert.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String()) + assert.Empty(t, rec.Body.Bytes(), "a 304 must not carry a body") + }) + + t.Run("Project without a background returns 404", func(t *testing.T) { + // testuser1 owns project 21, which has no background and isn't uploaded to + // by any other subtest (project 1 is, and subtests share this env). + rec := getBackgroundRequest(t, e, "21", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Read-only user may download", func(t *testing.T) { + // testuser6 owns project 35 and uploads a real background; testuser15 has + // read-only access, which CanRead allows for the download. Uploading first + // gives the file real bytes (the fixture row has none). + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotEmpty(t, rec.Body.Bytes(), "the read-only user must receive the bytes") + }) + + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := getBackgroundRequest(t, e, "35", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaProjectBackgroundDownloadDisabledByConfig verifies the download route is +// absent (404) when project backgrounds are disabled. +func TestHumaProjectBackgroundDownloadDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(false) + defer config.BackgroundsEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String()) +} + +// TestHumaUnsplashProxy covers the Unsplash image/thumb proxy routes' gating and auth. +// They only register when the unsplash provider is enabled (off by default), so the +// router is rebuilt with the flag on. The proxy's happy path needs the live Unsplash +// API and is therefore not covered here, matching v1 (which has no proxy tests). +func TestHumaUnsplashProxy(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Routes absent when unsplash is disabled", func(t *testing.T) { + // Unsplash is disabled by default; the proxy routes must not exist. + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "image proxy must be absent when unsplash is disabled; body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "thumb proxy must be absent when unsplash is disabled; body: %s", rec.Body.String()) + }) + + t.Run("Proxies require auth when unsplash is enabled", func(t *testing.T) { + config.BackgroundsUnsplashEnabled.Set(true) + defer config.BackgroundsUnsplashEnabled.Set(false) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "image proxy body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "thumb proxy body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_background_test.go b/pkg/webtests/huma_background_test.go new file mode 100644 index 000000000..8efdc3c2b --- /dev/null +++ b/pkg/webtests/huma_background_test.go @@ -0,0 +1,112 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaProjectBackgroundDelete covers removing a project background. It +// mirrors the v1 background_test.go matrix: the owner clears the background +// (and keeps the title), a read-only user is refused. +func TestHumaProjectBackgroundDelete(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner clears the background, title preserved", func(t *testing.T) { + // testuser6 owns project 35 (title "Test35 with background", background_file_id 1). + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 35} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, "Test35 with background", project.Title) + assert.Equal(t, int64(0), project.BackgroundFileID) + }) + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only (permission 0) access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaBackgroundDisabledByConfig verifies the registrar early-returns when +// project backgrounds are disabled: the DELETE route is then absent (404). +func TestHumaBackgroundDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(false) + defer config.BackgroundsEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String()) +} + +// TestHumaUnsplashBackground covers the Unsplash routes' auth and permission +// gates. They are only registered when the unsplash provider is enabled (off by +// default), so the router is rebuilt with the flag on. The set route's +// permission check runs before any Unsplash network call, so the negative cases +// are exercised without hitting the real API; the happy path needs the network +// and is therefore not covered here (matching v1). +func TestHumaUnsplashBackground(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(true) + config.BackgroundsUnsplashEnabled.Set(true) + defer config.BackgroundsUnsplashEnabled.Set(false) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + t.Run("Search requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/search?q=mountain", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set forbidden for read-only user", func(t *testing.T) { + // testuser15 has read-only access to project 35; CanUpdate fails before + // p.Set reaches Unsplash. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_background_upload_test.go b/pkg/webtests/huma_background_upload_test.go new file mode 100644 index 000000000..68755f53a --- /dev/null +++ b/pkg/webtests/huma_background_upload_test.go @@ -0,0 +1,151 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartFileBody builds a multipart body with a single file part under the +// given field name. CreateFormFile sets the part Content-Type to +// application/octet-stream, mirroring how many programmatic clients upload. +func multipartFileBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile(fieldName, filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func uploadBackgroundRequest(t *testing.T, e *echo.Echo, project, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/v2/projects/"+project+"/backgrounds/upload", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestHumaProjectBackgroundUpload(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner uploads a background", func(t *testing.T) { + // testuser1 owns project 1, which starts without a background. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 1} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.NotZero(t, project.BackgroundFileID, "the upload must set a background file id") + assert.NotEmpty(t, project.BackgroundBlurHash, "the upload must compute a blur hash") + }) + + t.Run("Non-image rejected with 400", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "not-an-image.txt", []byte("this is plain text, not an image")) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", "", body, contentType) + require.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Renders as multipart in the OpenAPI spec", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var spec map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + paths, _ := spec["paths"].(map[string]any) + op, _ := paths["/projects/{project}/backgrounds/upload"].(map[string]any) + put, ok := op["put"].(map[string]any) + require.True(t, ok, "PUT /projects/{project}/backgrounds/upload must be in the spec") + content, _ := put["requestBody"].(map[string]any) + contentMap, _ := content["content"].(map[string]any) + mp, ok := contentMap["multipart/form-data"].(map[string]any) + require.True(t, ok, "background upload must be modeled as multipart/form-data") + schema, _ := mp["schema"].(map[string]any) + props, _ := schema["properties"].(map[string]any) + bgProp, ok := props["background"].(map[string]any) + require.True(t, ok, "the background field must appear in the multipart schema") + assert.Equal(t, "binary", bgProp["format"], "background field must be a binary file in the spec") + }) +} + +// TestHumaProjectBackgroundUploadDisabledByConfig verifies the upload route is +// absent (404) when the upload provider is disabled, even though backgrounds +// themselves are enabled. +func TestHumaProjectBackgroundUploadDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsUploadEnabled.Set(false) + defer config.BackgroundsUploadEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when background upload is disabled; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_bulk_task_test.go b/pkg/webtests/huma_bulk_task_test.go new file mode 100644 index 000000000..f61141541 --- /dev/null +++ b/pkg/webtests/huma_bulk_task_test.go @@ -0,0 +1,74 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBulkTaskV2 covers PUT /tasks/bulk. It drives the Echo+Huma stack directly +// (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL only models +// base[/{id}] paths, not action sub-paths. +func TestBulkTaskV2(t *testing.T) { + t.Run("updates multiple tasks the user can write", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Tasks 1 and 2 both live in project 1, which testuser1 owns. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[1,2],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + db.AssertExists(t, "tasks", map[string]interface{}{"id": 1, "title": "bulkupdated"}, false) + db.AssertExists(t, "tasks", map[string]interface{}{"id": 2, "title": "bulkupdated"}, false) + }) + + t.Run("forbidden when missing write on one involved project", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 is in project 1 (owned), task 32 in project 3 (read-only share). + // CanUpdate fans the write check across both projects, so the whole + // request is rejected and neither task changes. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[1,32],"fields":["title"],"values":{"title":"shouldnothappen"}}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + db.AssertMissing(t, "tasks", map[string]interface{}{"title": "shouldnothappen"}) + }) + + t.Run("empty task_ids is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "") + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBulkTasksNeedAtLeastOne), "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_caldav_token_test.go b/pkg/webtests/huma_caldav_token_test.go new file mode 100644 index 000000000..f8e2663ee --- /dev/null +++ b/pkg/webtests/huma_caldav_token_test.go @@ -0,0 +1,165 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaCalDAVToken covers the v2 CalDAV token lifecycle. All calls share one +// echo env because setupTestEnv rotates the JWT signing key per call, which would +// 401 a token minted against an earlier env. +// +// Fixture (pkg/db/fixtures/user_tokens.yml): token id 6, kind 4 (CalDAV), +// belongs to user10. user1 starts with no CalDAV tokens. +func TestHumaCalDAVToken(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + user1Token := humaTokenFor(t, &testuser1) + user10Token := humaTokenFor(t, &testuser10) + + t.Run("Create returns the clear-text token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var created struct { + ID int64 `json:"id"` + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created), "body: %s", rec.Body.String()) + assert.NotZero(t, created.ID) + assert.NotEmpty(t, created.Token, "the clear-text token must be returned on create") + }) + + t.Run("List omits the token value", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + ids := caldavTokenIDsFromList(t, rec.Body.Bytes()) + assert.NotEmpty(t, ids, "the token created above must show up in the list") + assert.Empty(t, caldavTokenValuesFromList(t, rec.Body.Bytes()), + "the clear-text token must never appear in the list; body: %s", rec.Body.String()) + }) + + t.Run("List is scoped to the current user", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's fixture token #6 must be listed; body: %s", rec.Body.String()) + }) + + t.Run("Delete removes the token", func(t *testing.T) { + listRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String()) + ids := caldavTokenIDsFromList(t, listRec.Body.Bytes()) + require.NotEmpty(t, ids) + + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/"+strconv.FormatInt(ids[0], 10), "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + afterRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, afterRec.Code, "body: %s", afterRec.Body.String()) + assert.NotContains(t, caldavTokenIDsFromList(t, afterRec.Body.Bytes()), ids[0], + "the deleted token must be gone; body: %s", afterRec.Body.String()) + }) + + t.Run("Delete is scoped to the current user", func(t *testing.T) { + // Token #6 belongs to user10; user1 deleting it is a no-op (204), not an error. + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's token #6 must survive a delete attempt by another user; body: %s", rec.Body.String()) + }) +} + +// TestHumaCalDAVToken_LinkShareForbidden ports v1's implicit guard: a link share +// is not a user, so create / list / delete all refuse it (403). +func TestHumaCalDAVToken_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + t.Run("create", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("list", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("delete", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func caldavTokenIDsFromList(t *testing.T, body []byte) []int64 { + t.Helper() + items := caldavTokenItemsFromList(t, body) + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + return ids +} + +func caldavTokenValuesFromList(t *testing.T, body []byte) []string { + t.Helper() + values := []string{} + for _, it := range caldavTokenItemsFromList(t, body) { + if it.Token != "" { + values = append(values, it.Token) + } + } + return values +} + +func caldavTokenItemsFromList(t *testing.T, body []byte) []struct { + ID int64 `json:"id"` + Token string `json:"token"` +} { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + Token string `json:"token"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "list body must be a paginated envelope: %s", string(body)) + return resp.Items +} diff --git a/pkg/webtests/huma_info_test.go b/pkg/webtests/huma_info_test.go new file mode 100644 index 000000000..d9f2684a9 --- /dev/null +++ b/pkg/webtests/huma_info_test.go @@ -0,0 +1,47 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaInfo covers the public instance-info endpoint. It needs no auth and +// always reports the running version. +func TestHumaInfo(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/info", "", "", "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Contains(t, body, "version") + assert.Contains(t, body, "auth") + assert.Contains(t, body, "available_migrators") + + require.Contains(t, body, "concurrent_writes") + assert.Equal(t, config.DatabaseType.GetString() != "sqlite", body["concurrent_writes"]) +} diff --git a/pkg/webtests/huma_label_task_bulk_test.go b/pkg/webtests/huma_label_task_bulk_test.go new file mode 100644 index 000000000..3ee42e45f --- /dev/null +++ b/pkg/webtests/huma_label_task_bulk_test.go @@ -0,0 +1,117 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix +// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk. +// The body is the full target label set; the call adds missing labels and +// removes any not listed. +// +// Permission topology for testuser1 (see pkg/db/fixtures): +// - task 1 (project 1): owned by user1 → write. Has label #4 attached. +// - task 15 (project 6): shared via team 2 read-only → no write. +// - task 16 (project 7): shared via team 3 with write. +// - task 34 (project 20): private to user13 → no access. +// +// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1. +func TestLabelTaskBulk_V2(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + put := func(taskID, body string) (*v2ProblemJSON, []int64, int) { + t.Helper() + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "") + if rec.Code >= 400 { + var p v2ProblemJSON + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String()) + return &p, nil, rec.Code + } + var resp struct { + Labels []struct { + ID int64 `json:"id"` + } `json:"labels"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String()) + ids := make([]int64, 0, len(resp.Labels)) + for _, l := range resp.Labels { + ids = append(ids, l.ID) + } + return nil, ids, rec.Code + } + + t.Run("Replace adds and removes", func(t *testing.T) { + // task 1 starts with label #4; replacing with [#1] must add #1 and drop #4. + p, ids, code := put("1", `{"labels":[{"id":1}]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids, + "task 1's labels must be exactly {1} after replace") + }) + t.Run("Empty list clears all labels", func(t *testing.T) { + // task 16 (write-shared) gets a label, then an empty replace removes it. + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + + p, ids, code := put("16", `{"labels":[]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.Empty(t, ids, "empty replace must remove every label") + }) + t.Run("Write share can replace", func(t *testing.T) { + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + }) + t.Run("Read-only share is forbidden", func(t *testing.T) { + p, _, code := put("15", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Forbidden task", func(t *testing.T) { + // task 34 is private to user13. + p, _, code := put("34", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Nonexisting task", func(t *testing.T) { + p, _, code := put("9999", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusNotFound, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code) + }) + t.Run("Label the user cannot see is rejected", func(t *testing.T) { + // label #3 (user2's, attached to no task user1 can see) is invisible to + // user1; attaching it to a writable task must be refused. + p, _, code := put("1", `{"labels":[{"id":3}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code) + }) +} diff --git a/pkg/webtests/huma_label_test.go b/pkg/webtests/huma_label_test.go index 02b314dc5..eb59ea675 100644 --- a/pkg/webtests/huma_label_test.go +++ b/pkg/webtests/huma_label_test.go @@ -24,10 +24,17 @@ import ( "strings" "testing" + "code.vikunja.io/api/pkg/user" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// testuser22 is the second bot owner from pkg/db/fixtures/users.yml; user22 +// owns bot 24. Paired with testuser21 to assert bot-owner isolation: each +// owner sees and acts on their own bots' resources, never the other's. +var testuser22 = user.User{ID: 22, Username: "user_bot_owner_b", Issuer: "local"} + // TestHumaLabel mirrors v1's TestProject shape so v2 contract parity is // readable side-by-side. Labels has no v1 webtest; coverage is ported 1:1 // from the model-level matrix in pkg/models/label_test.go so the v2 HTTP @@ -228,6 +235,65 @@ func TestHumaLabel(t *testing.T) { }) } +// TestHumaLabel_BotOwner asserts that bot owners can read, update, and delete +// labels that were created by bots they own. Fixture label #9 is owned by +// bot 23, whose owner is user 21 (testuser21); user 22 owns a different bot +// and must not see or touch it. +func TestHumaLabel_BotOwner(t *testing.T) { + botOwner := webHandlerTestV2{ + user: &testuser21, + basePath: "/api/v2/labels", + idParam: "label", + t: t, + } + require.NoError(t, botOwner.ensureEnv()) + otherOwner := webHandlerTestV2{ + user: &testuser22, + basePath: "/api/v2/labels", + idParam: "label", + t: t, + e: botOwner.e, + } + + t.Run("ReadOne - bot owner can read label created by their bot", func(t *testing.T) { + rec, err := botOwner.testReadOneWithUser(nil, map[string]string{"label": "9"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"Label #9 - created by bot 23 owned by user 21"`) + }) + t.Run("ReadOne - non-owner cannot read another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testReadOneWithUser(nil, map[string]string{"label": "9"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("ReadAll - bot owner's listing surfaces their bot's labels", func(t *testing.T) { + rec, err := botOwner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + ids := labelIDsFromReadAll(t, rec.Body.Bytes()) + assert.Contains(t, ids, int64(9), "label #9 (created by user 21's bot) must be listed") + }) + t.Run("Update - bot owner can update label created by their bot", func(t *testing.T) { + rec, err := botOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"renamed by owner"}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"renamed by owner"`) + }) + t.Run("Update - non-owner cannot update another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"hijack"}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Delete - non-owner cannot delete another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testDeleteWithUser(nil, map[string]string{"label": "9"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Delete - bot owner can delete label created by their bot", func(t *testing.T) { + // Run last so the earlier subtests still have label #9 to operate on. + rec, err := botOwner.testDeleteWithUser(nil, map[string]string{"label": "9"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + }) +} + // labelIDsFromReadAll extracts the label IDs from a v2 paginated list body so // the visible set can be asserted exactly rather than via substring matching. func labelIDsFromReadAll(t *testing.T, body []byte) []int64 { diff --git a/pkg/webtests/huma_migration_csv_test.go b/pkg/webtests/huma_migration_csv_test.go new file mode 100644 index 000000000..ff269f46e --- /dev/null +++ b/pkg/webtests/huma_migration_csv_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const csvTestFile = `Title,Description,Done,Priority +Task 1,Description 1,true,high +Task 2,Description 2,false,low` + +const csvTestConfig = `{"delimiter":",","quote_char":"\"","date_format":"2006-01-02","mapping":[` + + `{"column_index":0,"column_name":"Title","attribute":"title"},` + + `{"column_index":1,"column_name":"Description","attribute":"description"},` + + `{"column_index":2,"column_name":"Done","attribute":"done"},` + + `{"column_index":3,"column_name":"Priority","attribute":"priority"}]}` + +// TestHumaMigrationCSV covers the generic CSV importer's v2 endpoints: +// status, detect, preview and migrate. No v1 webtest exists to mirror. +func TestHumaMigrationCSV(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run("detect returns columns and a suggested mapping", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"columns"`) + assert.Contains(t, rec.Body.String(), `"suggested_mapping"`) + assert.Contains(t, rec.Body.String(), "Title") + }) + + t.Run("preview returns tasks without importing", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/preview", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"tasks"`) + assert.Contains(t, rec.Body.String(), "Task 1") + }) + + t.Run("migrate imports the file", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Everything was migrated successfully."`) + + // The status now reflects a finished migration. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, + "after migrating, the status must carry a real started_at; body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_BadInput covers the negative paths: missing config, +// malformed config JSON, and an empty file. +func TestHumaMigrationCSV_BadInput(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("missing config is rejected with 422", func(t *testing.T) { + // The config form value is required:"true", so Huma's multipart + // validation refuses the request before the handler runs. + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("malformed config JSON is rejected with 400", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": "{not json"}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("empty file is rejected with a domain error", func(t *testing.T) { + body, contentType := multipartImportBody(t, "empty.csv", []byte{}, map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_Unauthenticated proves all CSV ops require auth. +func TestHumaMigrationCSV_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("detect", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_migration_file_test.go b/pkg/webtests/huma_migration_file_test.go new file mode 100644 index 000000000..9430127aa --- /dev/null +++ b/pkg/webtests/huma_migration_file_test.go @@ -0,0 +1,128 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartImportBody builds a multipart/form-data body with the file under the +// "import" field plus any extra string form values (e.g. the CSV "config"), +// matching the v2 file/CSV migrator form schemas. +func multipartImportBody(t *testing.T, filename string, content []byte, values map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("import", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + for k, v := range values { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func migrationUploadRequest(t *testing.T, e *echo.Echo, path string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaMigrationFile covers the always-registered file migrators +// (vikunja-file, ticktick, wekan) status + migrate endpoints. There is no v1 +// webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // payload is shaped per migrator to hit a *domain* rejection (4xx) rather + // than a raw parse error: a wekan board with no title/cards is "empty", a + // ticktick CSV with no data rows is "empty", and a vikunja-file that isn't + // a zip is rejected as such. (Syntactically-malformed input would surface a + // raw json/zip error that maps to 500 in both v1 and v2 alike.) + migrators := map[string][]byte{ + "vikunja-file": []byte("not a zip archive"), + "ticktick": []byte("Title,Content\n"), + "wekan": []byte(`{"title":"","cards":[]}`), + } + + for name, payload := range migrators { + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run(name+" migrate maps a rejected file to a 4xx domain error", func(t *testing.T) { + // Drives the request through the multipart binding and into the + // migrator, which rejects it with a domain error that + // translateDomainError turns into a 4xx — proving the v2 plumbing + // (bind, run, error bridge) is wired, not the parsing itself. + body, contentType := multipartImportBody(t, "bad."+name, payload, nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/"+name+"/migrate", body, contentType, token) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest, "body: %s", rec.Body.String()) + assert.Less(t, rec.Code, http.StatusInternalServerError, + "a rejected upload must map to a 4xx domain error, not a 500; body: %s", rec.Body.String()) + }) + } +} + +// TestHumaMigrationFile_Unauthenticated proves the file migrator ops require auth. +func TestHumaMigrationFile_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/ticktick/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "x.csv", []byte("x"), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationFile_MissingFile proves the required "import" form field is +// enforced by Huma's multipart validation (422), not a 500. +func TestHumaMigrationFile_MissingFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + require.NoError(t, w.Close()) + + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", buf, w.FormDataContentType(), token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_migration_oauth_test.go b/pkg/webtests/huma_migration_oauth_test.go new file mode 100644 index 000000000..7d15c576a --- /dev/null +++ b/pkg/webtests/huma_migration_oauth_test.go @@ -0,0 +1,153 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupMigrationTestEnv builds a test env with the OAuth migrators enabled so +// their v2 routes are registered (they are gated behind config flags that +// default to false). setupTestEnv resets config to defaults, so the flags must +// be set after it and the router rebuilt. +func setupMigrationTestEnv(t *testing.T) *echo.Echo { + t.Helper() + _, err := setupTestEnv() + require.NoError(t, err) + + // migration.Status is not part of models.GetTables() (pkg/models cannot + // import pkg/modules/migration without a cycle), so SetupTests never syncs + // migration_status. Create it here so the status/migrate handlers can query. + s := db.NewSession() + require.NoError(t, s.Sync2(&migration.Status{})) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + config.MigrationTodoistEnable.Set(true) + config.MigrationTrelloEnable.Set(true) + config.MigrationMicrosoftTodoEnable.Set(true) + t.Cleanup(func() { + config.MigrationTodoistEnable.Set(false) + config.MigrationTrelloEnable.Set(false) + config.MigrationMicrosoftTodoEnable.Set(false) + }) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// TestHumaMigrationOAuth covers the three OAuth migrators' v2 endpoints. There +// is no v1 webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationOAuth(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // The generic registration helper wires the same three ops for every + // migrator, so exercising each name guards against a copy-paste regression. + for _, name := range []string{"todoist", "trello", "microsoft-todo"} { + t.Run(name+" auth url", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/auth", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"url":"http`, "auth url must be returned; body: %s", rec.Body.String()) + }) + + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + } + + t.Run("migrate kicks off the migration", func(t *testing.T) { + events.ClearDispatchedEvents() + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + // 200, not the wrapper's POST default 201: this queues a job, it does + // not create a REST resource. + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Migration was started successfully."`) + events.AssertDispatched(t, &migrationHandler.MigrationRequestedEvent{}) + }) +} + +// TestHumaMigrationOAuth_AlreadyRunning ports v1's guard: starting a migration +// while one is already in progress (started, not finished) is refused with 412. +func TestHumaMigrationOAuth_AlreadyRunning(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + s := db.NewSession() + _, err := s.Insert(&migration.Status{ + UserID: testuser1.ID, + MigratorName: "todoist", + StartedAt: time.Now(), + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) + _ = s.Close() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) +} + +// TestHumaMigrationOAuth_Unauthenticated proves all three ops require auth. +func TestHumaMigrationOAuth_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"x"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationOAuth_Disabled proves a migrator's routes are absent when its +// config flag is off. +func TestHumaMigrationOAuth_Disabled(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + // All migration flags default to false after InitDefaultConfig. + + e := routes.NewEcho() + routes.RegisterRoutes(e) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "migration routes must not be registered when the flag is off; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_non_crud_aliases_test.go b/pkg/webtests/huma_non_crud_aliases_test.go new file mode 100644 index 000000000..1377507cd --- /dev/null +++ b/pkg/webtests/huma_non_crud_aliases_test.go @@ -0,0 +1,151 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// feedsTokenUser13 is a feeds-scoped API token for user 13 (see the feeds +// fixtures); it authenticates the v2 notifications Atom feed via HTTP Basic. +const feedsTokenUser13 = "tk_feeds_access_token_user_0013_feed0013" + +// TestHumaNonCRUDAliases covers the three non-REST endpoints mounted under +// /api/v2. Health and the Atom feed are Huma operations (so they appear in the +// OpenAPI spec); the WebSocket upgrade stays a raw echo route (OpenAPI can't +// model WebSockets). Each authenticates itself, so the group's JWT middleware +// must let them through. +func TestHumaNonCRUDAliases(t *testing.T) { + t.Run("health is public and returns OK", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/health", "", "", "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "OK") + }) + + t.Run("ws is reachable without a JWT", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + // A plain GET without the upgrade headers makes websocket.Accept reject + // the request (typically 400). The point is that it reaches the handler + // at all — not a 401 from the JWT middleware nor a 404 for an unmounted + // route. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/ws", "", "", "") + assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "ws must not be blocked by v2 JWT auth") + assert.NotEqual(t, http.StatusNotFound, rec.Code, "ws must be mounted under /api/v2") + }) + + t.Run("atom feed is basic-auth-gated, not JWT-gated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("without credentials returns a basic-auth challenge", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // The JWT middleware skips this path, so the handler's own HTTP Basic + // auth gates it instead: a 401 carrying a Basic challenge, not the JWT + // middleware's JSON error. + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, strings.ToLower(rec.Header().Get(echo.HeaderWWWAuthenticate)), "basic", + "expected a Basic auth challenge, got %q", rec.Header().Get(echo.HeaderWWWAuthenticate)) + }) + + t.Run("with a feeds API token returns an atom feed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + req.SetBasicAuth("user13", feedsTokenUser13) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.True(t, strings.HasPrefix(rec.Header().Get(echo.HeaderContentType), "application/atom+xml"), + "expected atom content type, got %q", rec.Header().Get(echo.HeaderContentType)) + assert.Contains(t, rec.Body.String(), ". + +package webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProjectDuplicateV2 covers POST /projects/{projectid}/duplicate. It drives +// the Echo+Huma stack directly (humaRequest/humaTokenFor) because +// webHandlerTestV2's buildURL only models base[/{id}] paths, not action sub-paths. +func TestProjectDuplicateV2(t *testing.T) { + t.Run("duplicates an accessible project to the top level", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Duplicating copies the source project's task attachments, so the + // referenced fixture file must exist in the (memory) file store. + files.InitTestFileFixtures(t) + token := humaTokenFor(t, &testuser1) + + // Project 1 is owned by testuser1. + const sourceProjectID int64 = 1 + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"duplicated_project"`) + + var resp struct { + DuplicatedProject struct { + ID int64 `json:"id"` + Title string `json:"title"` + ParentProjectID int64 `json:"parent_project_id"` + } `json:"duplicated_project"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotZero(t, resp.DuplicatedProject.ID, "duplicated project should have an id") + assert.NotEqual(t, sourceProjectID, resp.DuplicatedProject.ID, "duplicated project must have a new id, not the source project's") + assert.Contains(t, resp.DuplicatedProject.Title, "duplicate") + assert.Zero(t, resp.DuplicatedProject.ParentProjectID, "top-level duplicate must have no parent") + }) + + t.Run("places the duplicate under parent_project_id from the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + files.InitTestFileFixtures(t) + token := humaTokenFor(t, &testuser1) + + // testuser1 owns project 1, so it may both read the source and create + // the copy underneath it. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{"parent_project_id":1}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + DuplicatedProject struct { + ID int64 `json:"id"` + ParentProjectID int64 `json:"parent_project_id"` + } `json:"duplicated_project"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotZero(t, resp.DuplicatedProject.ID) + assert.Equal(t, int64(1), resp.DuplicatedProject.ParentProjectID, "duplicate must land under the requested parent") + }) + + t.Run("nonexistent source project", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/99999/duplicate", `{}`, token, "") + // CanCreate loads the source via CanRead, which surfaces + // ErrProjectDoesNotExist (404) for a missing project rather than a 403. + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist), "body must surface ErrCodeProjectDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no read on source project is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot read project 1 (owned by testuser1, no share). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_reaction_test.go b/pkg/webtests/huma_reaction_test.go new file mode 100644 index 000000000..56eac82e5 --- /dev/null +++ b/pkg/webtests/huma_reaction_test.go @@ -0,0 +1,103 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// reactionMapFromBody decodes the v2 reactions list body — a map keyed by +// reaction value, each value the list of users who reacted with it. +func reactionMapFromBody(t *testing.T, body []byte) map[string][]struct { + ID int64 `json:"id"` + Username string `json:"username"` +} { + t.Helper() + var m map[string][]struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + require.NoError(t, json.Unmarshal(body, &m), "list body must be a reaction map: %s", string(body)) + return m +} + +// TestHumaReaction exercises the v2 reaction surface, mirroring the v1 +// model-level matrix in pkg/models/reaction_test.go. Fixture reactions.yml +// seeds reaction #1: user1 reacted "👋" on task #1. +func TestHumaReaction(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("List returns the map with the reacting user", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + require.Len(t, m["👋"], 1, "fixture reaction must be present; body: %s", rec.Body.String()) + assert.Equal(t, int64(1), m["👋"][0].ID, "the reacting user is user1") + }) + + t.Run("Create then list reflects the new reaction", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions", `{"value":"🦙"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"value":"🦙"`) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + require.Len(t, m["🦙"], 1, "created reaction must appear in the list; body: %s", rec.Body.String()) + assert.Equal(t, int64(1), m["🦙"][0].ID) + }) + + t.Run("Delete removes the reaction", func(t *testing.T) { + // Remove the fixture reaction (user1's "👋" on task #1) and confirm via a follow-up list. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions/delete", `{"value":"👋"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "delete is POST-with-body returning 200; body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + assert.NotContains(t, m, "👋", "deleted reaction must be gone; body: %s", rec.Body.String()) + }) + + t.Run("Invalid entitykind is rejected", func(t *testing.T) { + // The enum tag on the path param makes Huma reject unknown kinds before the handler runs. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/loremipsum/1/reactions", "", token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Forbidden - no access to the entity", func(t *testing.T) { + // Task #34 lives in a private project user1 cannot see. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/reactions", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Nonexistent entity", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/9999999/reactions", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Create forbidden - no access to the entity", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/34/reactions", `{"value":"🦙"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_task_assignee_bulk_test.go b/pkg/webtests/huma_task_assignee_bulk_test.go new file mode 100644 index 000000000..0a54de02f --- /dev/null +++ b/pkg/webtests/huma_task_assignee_bulk_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTaskAssigneeBulk proves the v2 bulk-assignee replace contract: +// PUT /tasks/{projecttask}/assignees/bulk swaps the task's full assignee set +// for the posted list. Like the single-assignee test it gates on write access +// to the task's project (CanCreate → canDoTaskAssingee → project.CanUpdate). +// +// Fixture topology (pkg/db/fixtures/task_assignees.yml, tasks.yml, projects.yml, +// users_projects.yml): +// - task 30 (project 1, owned by user1): assignees user1 (#1) and user2 (#2). +// user2 is a fixture row only; user2 has NO access to project 1, so it can +// be removed but never freshly added — replace cases here only remove it. +// - tasks 16/19 (shared to user1 with write): user1 has project access, so +// it is a valid assignee there — used for the add-from-empty case. +// - tasks 15/18: shared read-only — write is forbidden. +// - task 34 (project 20, user13): user1 has no access at all. +func TestHumaTaskAssigneeBulk(t *testing.T) { + // One Echo env shared across users; setupTestEnv rotates the JWT secret per + // call, so a second env would 401 tokens minted against the first. + base := &webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, base.ensureEnv()) + + bulkPut := func(taskID string, u *user.User, payload string) (ids []int64, err error) { + h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees/bulk", t: t, e: base.e} + rec, err := h.serve(http.MethodPut, h.basePath, payload) + if err != nil { + return nil, err + } + // PUT defaults to 200 from the Register wrapper for a non-create verb. + assert.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + return assigneeIDsFromReadAll(t, rec.Body.Bytes()), nil + } + // readAssignees fetches the current assignee set so a replace is verified + // against persisted state, not just the response echo. + readAssignees := func(taskID string, u *user.User) []int64 { + h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees", idParam: "user", t: t, e: base.e} + rec, err := h.testReadAllWithUser(nil, nil) + require.NoError(t, err) + return assigneeIDsFromReadAll(t, rec.Body.Bytes()) + } + + t.Run("Replace removes assignees not in the list", func(t *testing.T) { + // task 30 starts as {1,2}; replacing with {1} must drop user2. + require.ElementsMatch(t, []int64{1, 2}, readAssignees("30", &testuser1)) + _, err := bulkPut("30", &testuser1, `{"assignees":[{"id":1}]}`) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1}, readAssignees("30", &testuser1), + "user2 must be unassigned after the replace") + }) + + t.Run("Empty list unassigns everyone", func(t *testing.T) { + // task 30 now holds {1}; an empty array clears it entirely. + _, err := bulkPut("30", &testuser1, `{"assignees":[]}`) + require.NoError(t, err) + assert.Empty(t, readAssignees("30", &testuser1), + "an empty assignees array must remove all assignees") + }) + + t.Run("Replace adds new assignees", func(t *testing.T) { + // task 16 is shared to user1 with write access and starts with no + // assignees; user1 has project access, so it is a valid new assignee. + require.Empty(t, readAssignees("16", &testuser1)) + _, err := bulkPut("16", &testuser1, `{"assignees":[{"id":1}]}`) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1}, readAssignees("16", &testuser1), + "user1 must be assigned after the replace") + }) + + t.Run("Forbidden - read-only share", func(t *testing.T) { + // task 18 is shared to user1 read-only; bulk replace needs write. + _, err := bulkPut("18", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Forbidden - no access at all", func(t *testing.T) { + // task 34 belongs to user13's private project 20. + _, err := bulkPut("34", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Forbidden - user without project access", func(t *testing.T) { + // user6 has no access to project 1, so it cannot write task 1. + _, err := bulkPut("1", &testuser6, `{"assignees":[{"id":6}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Nonexisting task", func(t *testing.T) { + // The write check resolves the project from the task, so a missing task + // surfaces project-does-not-exist as a 404. + _, err := bulkPut("99999", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist) + }) +} diff --git a/pkg/webtests/huma_task_attachment_test.go b/pkg/webtests/huma_task_attachment_test.go new file mode 100644 index 000000000..74d4ea0ed --- /dev/null +++ b/pkg/webtests/huma_task_attachment_test.go @@ -0,0 +1,258 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartFilesBody builds a multipart/form-data body with one or more files +// under the "files" field, matching the v2 upload handler's form schema. +func multipartFilesBody(t *testing.T, files map[string][]byte) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for filename, content := range files { + fw, err := w.CreateFormFile("files", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func uploadAttachmentRequest(t *testing.T, e *echo.Echo, taskID string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v2/tasks/"+taskID+"/attachments", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// uploadOneAttachment uploads a single file to task 1 and returns the created +// attachment id, so download/delete tests have a real file in storage to act on +// (setupTestEnv resets the mem storage, so fixture files have no bytes). +func uploadOneAttachment(t *testing.T, e *echo.Echo, token, filename string, content []byte) int64 { + t.Helper() + body, contentType := multipartFilesBody(t, map[string][]byte{filename: content}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Body struct { + Success []*models.TaskAttachment `json:"success"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp.Body)) + require.Empty(t, resp.Body.Errors, "upload reported per-file errors: %+v", resp.Body.Errors) + require.Len(t, resp.Body.Success, 1) + require.NotZero(t, resp.Body.Success[0].ID) + return resp.Body.Success[0].ID +} + +func TestTaskAttachmentsV2(t *testing.T) { + t.Run("Upload single file", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{"hello.txt": []byte("hello world")}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "hello.txt") + assert.Contains(t, rec.Body.String(), `"success"`) + }) + + t.Run("Upload multiple files", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{ + "one.txt": []byte("first file"), + "two.txt": []byte("second file"), + }) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Success []*models.TaskAttachment `json:"success"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(t, resp.Success, 2) + }) + + t.Run("List", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Upload first so there is at least one attachment with a real file row. + uploadOneAttachment(t, e, token, "listed.txt", []byte("listed content")) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Items []*models.TaskAttachment `json:"items"` + Total int64 `json:"total"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.Items) + assert.Positive(t, resp.Total) + }) + + t.Run("Download returns bytes and content type", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + content := []byte("downloadable content") + id := uploadOneAttachment(t, e, token, "download.txt", content) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "the streamed file bytes must match the original") + assert.NotEmpty(t, rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Header().Get("Content-Disposition"), "download.txt") + // Caching headers mirror v1: a concrete length and a cacheable directive. + assert.Equal(t, strconv.Itoa(len(content)), rec.Header().Get("Content-Length")) + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) + assert.NotEmpty(t, rec.Header().Get("Last-Modified")) + }) + + t.Run("Delete", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + id := uploadOneAttachment(t, e, token, "todelete.txt", []byte("bye")) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + + // The download must now 404. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Upload forbidden on inaccessible task", func(t *testing.T) { + // Task 34 is owned by user 13 and inaccessible to testuser1 (see the v1 IDOR test). + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{"nope.txt": []byte("nope")}) + rec := uploadAttachmentRequest(t, e, "34", body, contentType, token) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("List forbidden on inaccessible task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/attachments", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download nonexistent attachment", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/99999", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Cannot download attachment that does not belong to the task in the path", func(t *testing.T) { + // Mirrors the v1 IDOR test: attachment 4 belongs to task 34, not task 1. + // Requesting it under task 1 (accessible) must 404, not leak the file. + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/4", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated upload is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + body, contentType := multipartFilesBody(t, map[string][]byte{"x.txt": []byte("x")}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestTaskAttachmentsV2_PreviewSize covers the preview_size query param: a non-image +// attachment ignores it and returns the original bytes (the v1 behaviour). +func TestTaskAttachmentsV2_PreviewSize(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + content := []byte("not an image, just text") + id := uploadOneAttachment(t, e, token, "notimage.txt", content) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10)+"?preview_size=md", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "preview_size on a non-image must return the original file") +} + +// TestTaskAttachmentsV2_Disabled proves the resource is absent when the +// service.enabletaskattachments config flag is off. +func TestTaskAttachmentsV2_Disabled(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + oldValue := config.ServiceEnableTaskAttachments.GetBool() + config.ServiceEnableTaskAttachments.Set(false) + defer config.ServiceEnableTaskAttachments.Set(oldValue) + + // Rebuild the router so RegisterAll re-evaluates the (now disabled) flag. + e := routes.NewEcho() + routes.RegisterRoutes(e) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "attachment routes must not be registered when the flag is off; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_task_bucket_test.go b/pkg/webtests/huma_task_bucket_test.go new file mode 100644 index 000000000..e1623bf67 --- /dev/null +++ b/pkg/webtests/huma_task_bucket_test.go @@ -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 . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskBucketV2 covers PUT /projects/{project}/views/{view}/buckets/{bucket}/tasks. +// It drives the Echo+Huma stack directly (humaRequest/humaTokenFor) because the +// route is an action sub-path webHandlerTestV2's buildURL doesn't model. Fixtures +// (project 1, view 4): bucket 1 default, bucket 2 "Doing" limit 3 (full), bucket 3 done. +func TestTaskBucketV2(t *testing.T) { + const path = "/api/v2/projects/1/views/4/buckets/%d/tasks" + + t.Run("moves a task into a bucket", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 3 starts in bucket 2; move it into bucket 1 (neither full nor done). + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":3}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"task_id":3`) + + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 3, + "bucket_id": 1, + }, false) + }) + + t.Run("moving a task into the done bucket marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 3 is the done bucket on view 4; task 1 is not yet done. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 3), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":true`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 1, + "done": true, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 3, + }, false) + }) + + t.Run("moving a task out of the done bucket un-marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 2 starts in bucket 3 (done) and is done; move it to bucket 1. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":2}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":false`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 2, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 2, + "bucket_id": 1, + }, false) + }) + + t.Run("full bucket is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 2 already holds 3 tasks and has a limit of 3. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 2), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBucketLimitExceeded)) + }) + + t.Run("bucket on another view is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 4 lives on view 8 (project 2), so under view 4 / project 1 the + // permission check resolves the bucket's own view scoped by the path + // project and finds none → 404 before the move's own 400 can fire. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 4), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectViewDoesNotExist)) + }) + + t.Run("no write access is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 has no access to project 1. + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_task_collection_test.go b/pkg/webtests/huma_task_collection_test.go new file mode 100644 index 000000000..110b04b61 --- /dev/null +++ b/pkg/webtests/huma_task_collection_test.go @@ -0,0 +1,248 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodePaginatedTaskItems pulls the items slice out of a Paginated[*Task] +// response so length assertions don't have to regex over nested task JSON. +func decodePaginatedTaskItems(t *testing.T, rec *httptest.ResponseRecorder) []json.RawMessage { + t.Helper() + var body struct { + Items []json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Items +} + +// TestHumaTaskCollection covers the v2 task-list endpoints. v2 splits v1's +// single polymorphic /tasks endpoint into flat-task endpoints (always []*Task, +// paginated) and a dedicated buckets-with-tasks endpoint (always []*Bucket). +// Mirrors v1's TestTaskCollection where the surface overlaps. +func TestHumaTaskCollection(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("project-scoped", func(t *testing.T) { + t.Run("returns the project's tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.Contains(t, body, `task #12`) + assert.NotContains(t, body, `task #13`) // other project + assert.NotContains(t, body, `task #14`) + }) + t.Run("forbidden project", func(t *testing.T) { + // Project 2 is inaccessible to user1. + rec := get("/api/v2/projects/2/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("nonexistent project", func(t *testing.T) { + rec := get("/api/v2/projects/99999/tasks") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("pagination", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?page=1&per_page=2") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Len(t, decodePaginatedTaskItems(t, rec), 2, "per_page caps the page to two tasks") + body := rec.Body.String() + assert.Contains(t, body, `"page":1`) + assert.Contains(t, body, `"per_page":2`) + }) + t.Run("filter", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?filter=" + + "start_date%20%3E%20%272018-12-11T03%3A46%3A40%2B00%3A00%27%20%7C%7C%20" + + "end_date%20%3C%20%272018-12-13T11%3A20%3A01%2B00%3A00%27%20%7C%7C%20" + + "due_date%20%3E%20%272018-11-29T14%3A00%3A00%2B00%3A00%27") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.NotContains(t, body, `task #1`) + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #10`) + }) + t.Run("invalid filter value", func(t *testing.T) { + // ErrInvalidTaskFilterValue carries an explicit 400; only govalidator + // failures map to 422 in v2. + rec := get("/api/v2/projects/1/tasks?filter=due_date%20%3E%20invalid") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("search via q", func(t *testing.T) { + // Only task #6 has the word "unique" in its description. + rec := get("/api/v2/projects/1/tasks?q=unique") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #2 `) + }) + + t.Run("sort by repeated params", func(t *testing.T) { + // Two sort_by + two order_by prove ,explode binds every value. + rec := get("/api/v2/projects/1/tasks?sort_by=priority&sort_by=id&order_by=desc&order_by=asc") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // task #3 has priority 100, the highest; desc puts it first. + assert.Regexp(t, `"items":\[\{"id":3,`, rec.Body.String()) + }) + + t.Run("invalid sort field", func(t *testing.T) { + // A 400 (not 200) proves sort_by binds: the model validated the field + // and rejected it. ErrInvalidTaskField carries an explicit 400. + rec := get("/api/v2/projects/1/tasks?sort_by=loremipsum") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("cross-project", func(t *testing.T) { + // /tasks returns tasks from every project the user can see, including + // shared ones, but not tasks in projects they have no access to. + rec := get("/api/v2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) // own project + assert.Contains(t, body, `task #15`) // shared via team readonly + assert.Contains(t, body, `task #21`) // shared via parent project team + assert.NotContains(t, body, `task #13`) // no access + assert.NotContains(t, body, `task #14`) + }) + + t.Run("view-scoped", func(t *testing.T) { + t.Run("list view returns flat tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) // not buckets + }) + t.Run("kanban view still returns flat tasks", func(t *testing.T) { + // View 4 is project 1's kanban view. v1 would return buckets here; + // v2's tasks endpoint forces flat tasks. + rec := get("/api/v2/projects/1/views/4/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) + }) + t.Run("forbidden view", func(t *testing.T) { + // Project 2 (and its view 8) is inaccessible to user1. + rec := get("/api/v2/projects/2/views/8/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("saved filter project", func(t *testing.T) { + // Project -2 maps to saved filter #1, whose stored filter matches the + // date-range tasks. Recurses inside the model. + rec := get("/api/v2/projects/-2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #10`) + }) +} + +// TestHumaTaskCollection_Expand proves expand binds every repeated value +// (,explode) and routes through parseTaskExpand. +func TestHumaTaskCollection_Expand(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("repeated expand applies every value", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=comment_count&expand=reactions") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"comment_count":`) + assert.Contains(t, body, `"reactions":`) + }) + t.Run("invalid expand rejected", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=bogus") + // enum on the query param makes Huma reject before the handler. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTaskCollection_Buckets covers the dedicated buckets-with-tasks +// endpoint: a kanban view returns []*Bucket with each bucket's tasks populated, +// not paginated. +func TestHumaTaskCollection_Buckets(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("kanban view returns buckets with tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `testbucket1`) + assert.Contains(t, body, `testbucket2`) + assert.Contains(t, body, `testbucket3`) + assert.NotContains(t, body, `testbucket4`) // belongs to project 2's view + // Tasks are nested under their bucket, not at the top level. + assert.Contains(t, body, `"tasks":[`) + assert.Contains(t, body, `task #1`) + // total counts buckets, not tasks. + assert.Contains(t, body, `"total":3`) + }) + + t.Run("forbidden project", func(t *testing.T) { + rec := get("/api/v2/projects/2/views/8/buckets/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("non-kanban view is a 400, not a 500", func(t *testing.T) { + // View 1 is project 1's list view; it has no bucket configuration, so + // the model returns flat tasks and the handler refuses cleanly. + rec := get("/api/v2/projects/1/views/1/buckets/tasks") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("static tasks segment does not collide with the bucket-update route", func(t *testing.T) { + // PUT .../buckets/{bucket}/tasks exists; GET .../buckets/tasks must hit + // this handler, not parse "tasks" as a bucket id. + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `testbucket1`) + }) +} diff --git a/pkg/webtests/huma_task_position_test.go b/pkg/webtests/huma_task_position_test.go new file mode 100644 index 000000000..da10768e4 --- /dev/null +++ b/pkg/webtests/huma_task_position_test.go @@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma +// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL +// only models base[/{id}] paths, not action sub-paths. +func TestTaskPositionV2(t *testing.T) { + t.Run("updates the position of a writable task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL") + assert.Equal(t, int64(1), resp.ProjectViewID) + assert.InDelta(t, 256.0, resp.Position, 0) + }) + + t.Run("path task id wins over the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Body names task 2, URL names task 1; the URL must win. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID) + }) + + t.Run("nonexistent task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no access to the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot access task 1 (project 1, owned by testuser1). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("read but no write on the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Task 32 lives in project 3, on which testuser1 has read-only access. + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_task_relation_test.go b/pkg/webtests/huma_task_relation_test.go new file mode 100644 index 000000000..65166402c --- /dev/null +++ b/pkg/webtests/huma_task_relation_test.go @@ -0,0 +1,197 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskRelationV2 covers POST /tasks/{task}/relations and +// DELETE /tasks/{task}/relations/{relationKind}/{otherTask}. It drives the +// Echo+Huma stack directly (humaRequest/humaTokenFor) because the action +// sub-paths aren't modelled by webHandlerTestV2's buildURL. Coverage mirrors +// the v1 model matrix in pkg/models/task_relation_test.go. +func TestTaskRelationV2(t *testing.T) { + t.Run("Create", func(t *testing.T) { + t.Run("creates forward and inverse rows", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":2,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"relation_kind":"subtask"`) + assert.Contains(t, rec.Body.String(), `"task_id":1`) + assert.Contains(t, rec.Body.String(), `"other_task_id":2`) + + // Create must store both directions: the forward subtask and the + // automatically derived inverse parenttask. + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 2, + "relation_kind": models.RelationKindSubtask, + "created_by_id": 1, + }, false) + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 2, + "other_task_id": 1, + "relation_kind": models.RelationKindParenttask, + "created_by_id": 1, + }, false) + }) + + t.Run("path task id wins over body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task_id in the body is ignored; the row is created for the path task. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"task_id":999,"other_task_id":2,"relation_kind":"related"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 2, + "relation_kind": models.RelationKindRelated, + }, false) + }) + + t.Run("cycle is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task 29 is already a subtask of task 1 (fixture); making task 1 a + // subtask of task 29 would close the loop. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/29/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskRelationCycle)) + }) + + t.Run("same task is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":1,"relation_kind":"related"}`, token, "") + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationTasksCannotBeTheSame)) + }) + + t.Run("invalid relation kind in body is rejected by the enum", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // relation_kind carries an enum constraint, so Huma rejects an unknown + // kind with 422 before the handler runs (consistent with the delete path). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":2,"relation_kind":"bogus"}`, token, "") + require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("nonexistent base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/999999/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist)) + }) + + t.Run("forbidden - no write on base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task 15 is read-only for user1, so CanCreate (needs write on base) denies. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/15/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("removes forward and inverse rows", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Fixture relation 1: task 1 -subtask-> task 29, with the inverse + // parenttask row (task 29 -> task 1). + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/29", "", token, "") + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + assert.Empty(t, rec.Body.String()) + + db.AssertMissing(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 29, + "relation_kind": models.RelationKindSubtask, + }) + db.AssertMissing(t, "task_relations", map[string]interface{}{ + "task_id": 29, + "other_task_id": 1, + "relation_kind": models.RelationKindParenttask, + }) + }) + + t.Run("invalid relation kind in path is rejected by the enum", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // The path param carries an enum constraint, so Huma rejects an unknown + // kind with 422 before the handler runs. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/bogus/29", "", token, "") + require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("nonexistent relation", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/2", "", token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationDoesNotExist)) + }) + + t.Run("forbidden - no write on base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Fixture relation 7: task 41 -subtask-> task 43, owned by user15 in + // project 36, which user1 cannot access — CanDelete denies. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/41/relations/subtask/43", "", token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) +} diff --git a/pkg/webtests/huma_task_test.go b/pkg/webtests/huma_task_test.go new file mode 100644 index 000000000..8c919e382 --- /dev/null +++ b/pkg/webtests/huma_task_test.go @@ -0,0 +1,281 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTask mirrors v1's TestTask so v2 contract parity is readable +// side-by-side. Read/update/delete address a task by its numeric id; create +// and by-index live on project-scoped paths that don't fit the harness's +// basePath/{id} shape, so those use humaRequest against a shared env. +// +// Fixture topology the matrix relies on (pkg/db/fixtures/tasks.yml + +// project shares): +// - #1: user1's own task in project 1 (admin) — readable/updatable/deletable. +// - #14: project shared read-only via team — forbidden to write/delete. +// - #34: project 20, private to user13 — invisible to user1. +// - project 6: shared read-only; project 7/8: shared write/admin via team. +func TestHumaTask(t *testing.T) { + testHandler := webHandlerTestV2{ + user: &testuser1, + basePath: "/api/v2/tasks", + idParam: "projecttask", + t: t, + } + + t.Run("ReadOne", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"title":"task #1"`) + assert.Contains(t, rec.Body.String(), `"max_permission":2`) // owner = admin + assert.NotEmpty(t, rec.Result().Header.Get("ETag")) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "99999"}) + require.Error(t, err) + // CanRead resolves the task before the project check, so a missing + // task surfaces as 404, not the 403 the label read uses. + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - private project", func(t *testing.T) { + // Task #34 lives in project 20, private to user13. + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "34"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) + + // The v2 harness loads fixtures once and reuses the env across subtests, + // so each mutating subtest targets a distinct task to stay order-independent + // (unlike v1's webHandlerTest, which reloads fixtures per request). + t.Run("Update", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "3"}, `{"title":"Lorem Ipsum"}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + assert.NotContains(t, rec.Body.String(), `"title":"task #3 high prio"`) + }) + t.Run("Move to another project", func(t *testing.T) { + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "4"}, `{"project_id":7}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"project_id":7`) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "99999"}, `{"title":"x"}`) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "14"}, `{"title":"x"}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - move into a project the user can't write", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "5"}, `{"project_id":20}`) + require.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrorCodeGenericForbidden) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "2"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "99999"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "14"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Shared via team write", func(t *testing.T) { + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "16"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + }) + }) +} + +// TestHumaTask_Create covers the project-scoped create path, which the harness +// basePath shape can't express. Mirrors v1's TestTask/Create matrix. +func TestHumaTask_Create(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + + create := func(project, body string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodPost, "/api/v2/projects/"+project+"/tasks", body, humaTokenFor(t, &testuser1), "") + } + + t.Run("Normal", func(t *testing.T) { + rec := create("1", `{"title":"Lorem Ipsum"}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + }) + t.Run("Project id from body is ignored - URL wins", func(t *testing.T) { + rec := create("1", `{"title":"url wins","project_id":7}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + assert.NotContains(t, rec.Body.String(), `"project_id":7`) + }) + t.Run("Nonexisting project", func(t *testing.T) { + rec := create("9999", `{"title":"x"}`) + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist)) + }) + t.Run("Forbidden - private project", func(t *testing.T) { + rec := create("20", `{"title":"x"}`) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + rec := create("6", `{"title":"x"}`) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Shared via team write", func(t *testing.T) { + rec := create("7", `{"title":"Lorem Ipsum"}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + }) + t.Run("Empty title is rejected", func(t *testing.T) { + rec := create("1", `{"title":""}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_ReadByIndex covers the by-index route, including the textual +// project-identifier resolution that v1 does in echo middleware. Mirrors v1's +// TestTaskByProjectIndex and TestTask/ReadOneByIndex. +func TestHumaTask_ReadByIndex(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + + get := func(project, index string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, + fmt.Sprintf("/api/v2/projects/%s/tasks/by-index/%s", project, index), "", humaTokenFor(t, &testuser1), "") + } + + t.Run("By numeric project id", func(t *testing.T) { + rec := get("1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"index":1`) + }) + t.Run("By textual project identifier", func(t *testing.T) { + // Project 1 has identifier "TEST1". + rec := get("TEST1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + t.Run("Identifier match is case-insensitive", func(t *testing.T) { + rec := get("test1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + t.Run("Unknown identifier returns ErrProjectDoesNotExist", func(t *testing.T) { + rec := get("does-not-exist", "1") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist)) + }) + t.Run("Nonexistent index returns 404", func(t *testing.T) { + rec := get("1", "99999") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("No permission returns 403", func(t *testing.T) { + // Project 2 is inaccessible to user1; must be 403, not a 404 oracle. + rec := get("2", "1") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_Expand asserts the expand query param populates the extra, +// more expensive fields, is repeatable (explode), and rejects unknown values. +// comment_count and reactions are genuinely gated on the flag, so they prove +// the param is wired through; subtasks-as-related-tasks load regardless. +func TestHumaTask_Expand(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + t.Run("absent leaves expand-gated fields empty", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"comment_count":`) + assert.NotContains(t, rec.Body.String(), `"reactions":{`) + }) + t.Run("comment_count", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"comment_count":`, "comment_count must be present: %s", rec.Body.String()) + }) + t.Run("reactions", func(t *testing.T) { + // Task #1 has reaction fixture #1. + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=reactions", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"reactions":{`, "reactions must be embedded: %s", rec.Body.String()) + }) + t.Run("repeated param applies every value", func(t *testing.T) { + // explode binding: both ?expand= values take effect, not just the first. + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count&expand=reactions", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"comment_count":`) + assert.Contains(t, rec.Body.String(), `"reactions":{`) + }) + t.Run("invalid value is rejected", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=bogus", "", tok, "") + // enum on the query param makes Huma reject it as a 422 before the handler. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_ETagReturns304 covers the v2-only conditional-read behaviour. +func TestHumaTask_ETagReturns304(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + etag := rec.Header().Get("ETag") + require.NotEmpty(t, etag) + + req := httptest.NewRequest(http.MethodGet, "/api/v2/tasks/1", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("If-None-Match", etag) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_task_unread_status_test.go b/pkg/webtests/huma_task_unread_status_test.go new file mode 100644 index 000000000..9ea27544d --- /dev/null +++ b/pkg/webtests/huma_task_unread_status_test.go @@ -0,0 +1,88 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTaskUnreadStatus ports v1's POST /tasks/:projecttask/read (no v1 +// webtest exists). The action deletes the caller's unread entry for the task; +// there is no fixture file for task_unread_statuses, so the table starts empty +// and the test seeds the row it expects to clear. +// +// Note on the permission model: the v1 handler enforces nothing — CanUpdate is +// a hardcoded true and Update is an unconditional DELETE on (task_id, user_id). +// A task the caller can't see (or doesn't exist) therefore has no row to clear +// and the call succeeds as a no-op. The only thing actually gated is auth, so +// that is what the negative case covers. +func TestHumaTaskUnreadStatus(t *testing.T) { + t.Run("Normal - clears the caller's unread entry", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + s := db.NewSession() + _, err = s.Insert(&models.TaskUnreadStatus{TaskID: 1, UserID: testuser1.ID}) + require.NoError(t, err) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + + db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{ + "task_id": 1, + "user_id": testuser1.ID, + }) + }) + + t.Run("No-op - already read, no entry to clear", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + }) + + t.Run("No-op - nonexistent task (unenforced, mirrors v1)", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Anonymous request is rejected with 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_testing_test.go b/pkg/webtests/huma_testing_test.go new file mode 100644 index 000000000..480ef285f --- /dev/null +++ b/pkg/webtests/huma_testing_test.go @@ -0,0 +1,223 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "src.techknowlogick.com/xormigrate" +) + +const testingToken = "test-testing-token" + +// setupTestingEnv mirrors setupTestEnv but sets the testing token before +// registering routes, so the config-gated /api/v2/test/* endpoints mount. +// When token is empty the endpoints stay unmounted (the disabled case). +func setupTestingEnv(t *testing.T, token string) *echo.Echo { + t.Helper() + config.InitDefaultConfig() + config.ServicePublicURL.Set("https://localhost") + config.ServiceTestingtoken.Set(token) + t.Cleanup(func() { config.ServiceTestingtoken.Set("") }) + + log.InitLogger() + files.InitTests() + user.InitTests() + models.SetupTests() + events.Fake() + keyvalue.InitStorage() + + // models.SetupTests only syncs models + notifications tables, but + // TruncateAllTables walks *every* registered table — including ones created + // by migration in production (license_status, migration_status) plus + // xormigrate's "migration" tracking table. Create them here so truncate-all + // doesn't hit "no such table" (the same gap that kept v1 from testing it). + engine, err := db.CreateTestEngine() + require.NoError(t, err) + extraTables := append(append([]any{new(xormigrate.Migration)}, license.GetTables()...), migration.GetTables()...) + require.NoError(t, engine.Sync2(extraTables...)) + + require.NoError(t, db.LoadFixtures()) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// testingRequest dispatches a request to a /api/v2/test/* endpoint, sending the +// raw token in the Authorization header (not a Bearer JWT). +func testingRequest(e *echo.Echo, method, path, body, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func countRows(t *testing.T, table string) int { + t.Helper() + s := db.NewSession() + defer s.Close() + rows := []map[string]interface{}{} + require.NoError(t, s.Table(table).Find(&rows)) + return len(rows) +} + +func TestTesting(t *testing.T) { + t.Run("replace table contents", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]` + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", body, testingToken) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var data []map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &data)) + require.Len(t, data, 1) + assert.EqualValues(t, "only label", data[0]["title"]) + assert.Equal(t, 1, countRows(t, "labels"), "table should hold exactly the seeded rows") + }) + + t.Run("replace without truncate keeps existing rows", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + before := countRows(t, "labels") + require.Positive(t, before, "fixtures should seed some labels") + + body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]` + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels?truncate=false", body, testingToken) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + assert.Equal(t, before+1, countRows(t, "labels"), "row should be added on top of existing data") + }) + + t.Run("truncate all tables", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + require.Positive(t, countRows(t, "labels")) + + rec := testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", testingToken) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "ok", resp.Message) + assert.Equal(t, 0, countRows(t, "labels"), "every table should be empty after truncate") + }) + + t.Run("wrong token is forbidden", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "wrong-token") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "wrong-token") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("missing token is forbidden", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestTesting_DisabledConfig(t *testing.T) { + e := setupTestingEnv(t, "") + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured") + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "") + assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured") +} + +func TestTesting_BodySchemaIsArrayOfObjects(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var spec map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + paths, _ := spec["paths"].(map[string]any) + op, _ := paths["/test/{table}"].(map[string]any) + put, ok := op["put"].(map[string]any) + require.True(t, ok, "PUT /test/{table} must be in the spec") + + reqBody, _ := put["requestBody"].(map[string]any) + content, _ := reqBody["content"].(map[string]any) + appJSON, _ := content["application/json"].(map[string]any) + schema, _ := appJSON["schema"].(map[string]any) + // FieldsOptionalByDefault makes the array nullable, so `type` may be the + // string "array" or the list ["array","null"]. Either is honest; assert it + // describes an array (not, say, a base64 string as json.RawMessage would). + assert.Contains(t, schemaTypes(schema["type"]), "array", "request body must be modeled as an array") +} + +// schemaTypes normalises an OpenAPI `type` value (a string or a list of +// strings when nullable) into a slice for assertion. +func schemaTypes(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} diff --git a/pkg/webtests/huma_time_entry_test.go b/pkg/webtests/huma_time_entry_test.go new file mode 100644 index 000000000..1b8b91ffb --- /dev/null +++ b/pkg/webtests/huma_time_entry_test.go @@ -0,0 +1,211 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/license" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixture entries (pkg/db/fixtures/time_entries.yml): 1 = user1 on task 1, +// 2 = user1 on project 1, 3 = user3 on project 3 (user1 can read), 4 = user1's +// running timer on task 1. user1 (testuser1) can read all four. + +// The gate is the one v2-specific concern with no model-level equivalent: every +// time-tracking route 404s on an instance without the feature. +func TestHumaTimeEntry_LicenseGate(t *testing.T) { + t.Run("disabled feature 404s the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) // licensed, but not time tracking + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s timer/stop", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s the task-scoped list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("enabled feature serves the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +func TestHumaTimeEntry(t *testing.T) { + // SetForTests must come after setupTestEnv — the latter re-inits the license to free. + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + testHandler := webHandlerTestV2{ + user: &testuser1, + basePath: "/api/v2/time-entries", + idParam: "id", + t: t, + e: e, + } + + t.Run("ReadAll returns the readable set", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1, 2, 3, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()), + "body: %s", rec.Body.String()) + }) + + t.Run("ReadOne", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"max_permission":`) + }) + + t.Run("ReadOne forbidden for a stranger", func(t *testing.T) { + // entry 1 is on project 1; user2 has no access to it. + stranger := webHandlerTestV2{user: &testuser2, basePath: "/api/v2/time-entries", idParam: "id", t: t, e: e} + _, err := stranger.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("ReadOne of a missing entry is 404", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) +} + +// Create exercises the full handler path (DoCreate → CanCreate → Create → +// commit → DispatchPending) that the model-level tests bypass. +func TestHumaTimeEntry_Create(t *testing.T) { + t.Run("saving an entry with end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","end_time":"2020-01-01T10:00:00Z","comment":"work"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + assert.Contains(t, rec.Body.String(), `"task_id":1`) + }) + + t.Run("starting a timer without end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","comment":"timer"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + // A running timer's end_time is null on the wire, not the zero-time sentinel. + assert.Contains(t, rec.Body.String(), `"end_time":null`) + assert.NotContains(t, rec.Body.String(), "0001-01-01") + }) +} + +// The filter param must wire through the route into ReadAll. +func TestHumaTimeEntry_Filter(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + token := humaTokenFor(t, &testuser1) + + t.Run("by task", func(t *testing.T) { + q := url.Values{"filter": {"task_id = 1"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{1, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) + + t.Run("running timers via null end_time", func(t *testing.T) { + q := url.Values{"filter": {"end_time = null"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) +} + +func TestHumaTimeEntry_TimerStop(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + t.Run("stops the caller's running timer", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":4`, "entry 4 is user1's running timer") + assert.NotContains(t, rec.Body.String(), `"end_time":"0001-01-01`, "end_time must now be set") + }) + + t.Run("404 when the caller has no running timer", func(t *testing.T) { + // user2 has no entries, so no running timer. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser2), "") + assert.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) + }) +} + +func timeEntryIDsFromReadAll(t *testing.T, body []byte) []int64 { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body)) + ids := make([]int64, 0, len(resp.Items)) + for _, it := range resp.Items { + ids = append(ids, it.ID) + } + return ids +} diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go new file mode 100644 index 000000000..081db594d --- /dev/null +++ b/pkg/webtests/huma_user_deletion_test.go @@ -0,0 +1,194 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + userDeletionRequestPath = "/api/v2/user/deletion/request" + userDeletionConfirmPath = "/api/v2/user/deletion/confirm" + userDeletionCancelPath = "/api/v2/user/deletion/cancel" + // testUserPassword is the plaintext password for every local fixture user. + testUserPassword = "12345678" +) + +// deletionTokenFor reads the cleartext account-deletion token RequestDeletion +// stored for the user. RequestDeletion only mails the token, so the test pulls +// it straight from user_tokens (kind 3 = TokenAccountDeletion). +func deletionTokenFor(t *testing.T, userID int64) string { + t.Helper() + s := db.NewSession() + defer s.Close() + tok := struct { + Token string `xorm:"token"` + }{} + has, err := s.Table("user_tokens"). + Where("user_id = ? AND kind = ?", userID, 3). + Get(&tok) + require.NoError(t, err) + require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID) + return tok.Token +} + +func deletionScheduledFor(t *testing.T, userID int64) bool { + t.Helper() + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, userID) + require.NoError(t, err) + return !u.DeletionScheduledAt.IsZero() +} + +// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm → +// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2 +// normalises all three to an empty 204 (the action returns no resource), so +// every success here asserts 204 + empty body. +func TestHumaUserDeletion(t *testing.T) { + t.Run("Request - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion") + }) + + t.Run("Confirm - invalid token rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID)) + }) + + t.Run("Confirm - missing token is a validation error", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Request then confirm schedules deletion", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204") + assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does") + + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + assert.Empty(t, confirm.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion") + }) + + t.Run("Cancel - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Schedule first so there is something to cancel. + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled") + }) + + t.Run("Cancel - correct password clears the schedule", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String()) + assert.Empty(t, cancel.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion") + }) + + t.Run("Unauthenticated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} { + rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String()) + } + }) +} + +// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no +// account — is refused (403) on every deletion action. +func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + body string + }{ + {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`}, + {"confirm", userDeletionConfirmPath, `{"token":"x"}`}, + {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + } +} diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go new file mode 100644 index 000000000..ee9104d5b --- /dev/null +++ b/pkg/webtests/huma_user_export_test.go @@ -0,0 +1,126 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/files" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserExport covers the v2 data-export endpoints. Fixture topology +// (pkg/db/fixtures/users.yml + files.yml): +// - user1: local, password 12345678, export_file_id 1 (file row exists, no bytes). +// - user14: non-local (OIDC), no password to confirm. +// - user15: local, no export. +func TestHumaUserExport(t *testing.T) { + t.Run("Request with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "requested data export") + }) + + t.Run("Request with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, + "a wrong password must not start an export; body: %s", rec.Body.String()) + }) + + t.Run("Request as a non-local user skips the password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{}`, humaTokenFor(t, &testuser14), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download streams the export bytes", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's export points at file 1; setupTestEnv resets storage, so write + // real bytes for it (size matches the fixture's declared 100 bytes). + content := bytes.Repeat([]byte("v"), 100) + require.NoError(t, (&files.File{ID: 1, Size: uint64(len(content))}).Save(bytes.NewReader(content))) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match") + assert.Contains(t, rec.Header().Get("Content-Disposition"), "test") + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), "downloads must never be cached") + }) + + t.Run("Download with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download without an export returns 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download with a missing physical file returns 404", func(t *testing.T) { + // user1 has export_file_id 1, but setupTestEnv leaves its bytes unwritten. + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Status returns the export metadata", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"expires"`) + }) + + t.Run("Status without an export returns null", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.JSONEq(t, "null", rec.Body.String()) + }) + + t.Run("Unauthenticated request is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_user_search_test.go b/pkg/webtests/huma_user_search_test.go new file mode 100644 index 000000000..821095ea6 --- /dev/null +++ b/pkg/webtests/huma_user_search_test.go @@ -0,0 +1,89 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserSearch covers the global user search. Emails must never leak. +func TestHumaUserSearch(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Search by username", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + usernames, emails := usersFromSearch(t, rec.Body.Bytes()) + assert.Contains(t, usernames, "user2") + for _, em := range emails { + assert.Empty(t, em, "user search must never return email addresses") + } + }) + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaProjectUserSearch covers the per-project user search used for share +// autocomplete. It requires read access to the project. +func TestHumaProjectUserSearch(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Owned project", func(t *testing.T) { + // testuser1 owns project 1. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/users/search", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"items"`) + }) + t.Run("Forbidden - no access", func(t *testing.T) { + // project 2 is owned by user3; testuser1 has no access. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/2/users/search", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Nonexistent project", func(t *testing.T) { + // CanRead surfaces ErrProjectDoesNotExist (404), not a bare forbidden. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/99999/users/search", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func usersFromSearch(t *testing.T, body []byte) (usernames, emails []string) { + t.Helper() + var resp struct { + Items []struct { + Username string `json:"username"` + Email string `json:"email"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "search body must be a paginated envelope: %s", string(body)) + for _, it := range resp.Items { + usernames = append(usernames, it.Username) + emails = append(emails, it.Email) + } + return usernames, emails +} diff --git a/pkg/webtests/huma_user_settings_test.go b/pkg/webtests/huma_user_settings_test.go new file mode 100644 index 000000000..24e7469f3 --- /dev/null +++ b/pkg/webtests/huma_user_settings_test.go @@ -0,0 +1,195 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All subtests in a Test* func share one env: setupTestEnv rotates the JWT +// secret per call, so a token must be issued from the same env it's used +// against. Where a subtest mutates the user, later subtests account for it. + +func TestHumaUserShow(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, `"id":1`) + assert.Contains(t, body, `"username":"user1"`) + // Like v1, /user does not disclose the email (GetUserByID strips it); the + // json:"email,omitempty" tag then drops the field entirely. + assert.NotContains(t, body, `"email":""`) + // Computed account facts v1 returned alongside the user object. + assert.Contains(t, body, `"auth_provider":"local"`) + assert.Contains(t, body, `"is_local_user":true`) + assert.Contains(t, body, `"is_admin":false`) + // The nested settings use the shared models.UserGeneralSettings shape. + assert.Contains(t, body, `"settings":`) + assert.Contains(t, body, `"frontend_settings":`) + assert.Contains(t, body, `"extra_settings_links":`) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestHumaUserChangePassword(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong old password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"invalid","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Empty old password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("New password too short", func(t *testing.T) { + // v2 maps govalidator failures (bcrypt_password) to 422, not v1's 412. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"1234567"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal - run last, it changes the password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"123456789"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) +} + +func TestHumaUserUpdateEmail(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"invalid"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Missing new email", func(t *testing.T) { + // new_email carries valid:"...,required"; v2 maps the failure to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"password":"12345678"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"12345678"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "confirm your email address") + }) +} + +func TestHumaUserUpdateSettings(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"name":"New Name","week_start":1,"overdue_tasks_reminders_time":"10:00","timezone":"Europe/Berlin"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The settings were updated successfully.") + + // The change is observable through user-show. + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + assert.Contains(t, show.Body.String(), `"name":"New Name"`) + }) + t.Run("Frontend settings round-trip as arbitrary JSON", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"overdue_tasks_reminders_time":"09:00","frontend_settings":{"color_schema":"dark","nested":{"a":1}}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + var resp struct { + Settings struct { + FrontendSettings map[string]any `json:"frontend_settings"` + } `json:"settings"` + } + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &resp)) + assert.Equal(t, "dark", resp.Settings.FrontendSettings["color_schema"]) + }) + t.Run("Invalid week_start", func(t *testing.T) { + // week_start carries valid:"range(0|6)"; out of range maps to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"week_start":9,"overdue_tasks_reminders_time":"09:00"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserAvatarProvider(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Get", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":`) + }) + t.Run("Set then get reflects the change", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"initials"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":"initials"`) + + get := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, get.Code) + assert.Contains(t, get.Body.String(), `"avatar_provider":"initials"`) + }) + t.Run("Invalid provider", func(t *testing.T) { + // UpdateUser rejects unknown providers with ErrInvalidAvatarProvider (412). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"nonsense"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserTimezones(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/timezones", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var zones []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &zones)) + assert.NotEmpty(t, zones) + assert.Contains(t, zones, "Europe/Berlin") +} diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go new file mode 100644 index 000000000..df244a23c --- /dev/null +++ b/pkg/webtests/huma_user_totp_test.go @@ -0,0 +1,144 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/user" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testuser14 is a non-local (OIDC) account; totp is local-only, so every totp +// route must refuse it. See pkg/db/fixtures/users.yml. +var testuser14 = user.User{ID: 14, Username: "user14", Issuer: "https://some.service.com"} + +// TestHumaTOTP mirrors v1's TestUserTOTPLocalUser and adds the enable/disable +// flows, the qr-code blob endpoint, and the local-account-only guard. +// +// Fixture topology (pkg/db/fixtures/totp.yml + users.yml): +// - user1: totp enrolled, not enabled (secret HXDMVJEC…). +// - user10: totp enabled (secret JBSWY3DP…), local, password 12345678. +// - user15: local, no totp enrollment. +// - user14: non-local (OIDC) account. +func TestHumaTOTP(t *testing.T) { + t.Run("Get status for enrolled user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Get status without enrollment returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Get qr code for enrolled user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp/qrcode", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) + assert.NotEmpty(t, rec.Body.Bytes(), "the qr code jpeg must have bytes") + }) + + t.Run("Enroll a fresh user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user15 has no totp enrollment in the fixtures. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"url"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Enroll when already enrolled returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Enable with a valid passcode", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's fixture secret; generate a passcode that is valid right now. + passcode, err := totp.GenerateCode("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", time.Now()) + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + fmt.Sprintf(`{"passcode":%q}`, passcode), humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "enabled successfully") + }) + + t.Run("Enable with an invalid passcode returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + `{"passcode":"000000"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Disable with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user10 has totp enabled; 12345678 is their fixture password. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"12345678"}`, humaTokenFor(t, &testuser10), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "disabled successfully") + }) + + t.Run("Disable with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser10), "") + require.NotEqual(t, http.StatusOK, rec.Code, "wrong password must not disable totp; body: %s", rec.Body.String()) + }) + + t.Run("Non-local user is refused on every route", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser14) + for _, tc := range []struct { + method, path, body string + }{ + {http.MethodGet, "/api/v2/user/settings/totp", ""}, + {http.MethodGet, "/api/v2/user/settings/totp/qrcode", ""}, + {http.MethodPost, "/api/v2/user/settings/totp/enroll", ""}, + {http.MethodPost, "/api/v2/user/settings/totp/enable", `{"passcode":"000000"}`}, + {http.MethodPost, "/api/v2/user/settings/totp/disable", `{"password":"12345678"}`}, + } { + rec := humaRequest(t, e, tc.method, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "%s %s must refuse a non-local account; body: %s", tc.method, tc.path, rec.Body.String()) + } + }) +} diff --git a/pkg/webtests/huma_user_webhook_test.go b/pkg/webtests/huma_user_webhook_test.go new file mode 100644 index 000000000..8c061a6ff --- /dev/null +++ b/pkg/webtests/huma_user_webhook_test.go @@ -0,0 +1,189 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserWebhook ports the v1 user-webhook coverage (the per-user sibling of +// the project webhooks tested in TestHumaWebhook) to /api/v2. User webhooks live +// at /user/settings/webhooks{,/{webhook}} — list, events, create, update, delete; +// there is deliberately no ReadOne (webhooks carry credentials). +// +// Ownership gradient — a user webhook is owned by its UserID, and every Can* boils +// down to "are you that user". Fixtures: webhooks #6/#7 belong to user6, #8 to +// user1. The actor is user6 (not user1): the user-webhook e2e tests dispatch +// user-directed events only for users 1 and 2, so user6-owned fixtures never fire +// there. The point of these cases is that user6 sees and mutates only their own +// webhooks and is forbidden on user1's. +func TestHumaUserWebhook(t *testing.T) { + // availableWebhookEvents / userDirectedWebhookEvents are populated by + // RegisterListeners(), which the webtests harness does not call. Register the + // one user-directed event the fixtures and these cases use so Create/Update + // validation accepts it. + models.RegisterUserDirectedEventForWebhook(&models.TaskReminderFiredEvent{}) + + owner := webHandlerTestV2{ + user: &testuser6, + basePath: "/api/v2/user/settings/webhooks", + idParam: "webhook", + t: t, + } + require.NoError(t, owner.ensureEnv()) + + t.Run("ReadAll", func(t *testing.T) { + t.Run("Normal - sees only own webhooks", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + ids := webhookIDsFromReadAll(t, rec.Body.Bytes()) + // user6 owns #6 and #7; #8 belongs to user1 and must not appear. + assert.ElementsMatch(t, []int64{6, 7}, ids, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"target_url"`) + }) + t.Run("Secret and basic auth credentials are never exposed", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.NotContains(t, rec.Body.String(), `uwh-secret-fixture`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-user`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-pass`) + }) + }) + + t.Run("Events", func(t *testing.T) { + // The events route reports only user-directed events. task.reminder.fired + // is registered above; task.updated (project-only) must not be listed. + token := humaTokenFor(t, &testuser6) + rec := humaRequest(t, owner.e, http.MethodGet, "/api/v2/user/settings/webhooks/events", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + var events []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events), "body: %s", rec.Body.String()) + assert.Contains(t, events, "task.reminder.fired") + assert.NotContains(t, events, "task.updated") + }) + + t.Run("Create", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/new","events":["task.reminder.fired"]}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`) + // Ownership comes from the token, not the body. + assert.Contains(t, rec.Body.String(), `"user_id":6`) + }) + t.Run("Secret and basic auth are not echoed back", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/secret","events":["task.reminder.fired"],"secret":"top-secret","basic_auth_user":"u","basic_auth_password":"p"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.NotContains(t, rec.Body.String(), `top-secret`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_user":"u"`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_password":"p"`) + }) + t.Run("Non user-directed event rejected", func(t *testing.T) { + // task.updated is a project event, not user-directed; Create rejects it + // → InvalidFieldError, surfaced as 422 on v2. + _, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/x","events":["task.updated"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + t.Run("Missing target url", func(t *testing.T) { + _, err := owner.testCreateWithUser(nil, nil, `{"events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + }) + + t.Run("Update", func(t *testing.T) { + t.Run("Normal - only events change", func(t *testing.T) { + rec, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "6"}, + `{"events":["task.reminder.fired"],"target_url":"https://example.com/ignored"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":6`) + + rec, err = owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `https://example.com/user-webhook-fixture`, + "target_url must stay the fixture value; only events are mutable") + assert.NotContains(t, rec.Body.String(), `https://example.com/ignored`) + }) + t.Run("Cannot update another user's webhook", func(t *testing.T) { + // webhook #8 belongs to user1; canDoWebhook resolves ownership from the + // stored row, so user6 is forbidden regardless of the URL. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "8"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + // canDoWebhook returns false for a missing webhook → 403, not 404. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "9999"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Cannot delete another user's webhook", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "8"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "7"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + }) + }) +} + +// TestHumaUserWebhook_DisabledByConfig confirms RegisterUserWebhookRoutes skips +// the resource when webhooks.enabled is false, so the v2 user-webhook routes 404 +// rather than running with the feature toggled off. +func TestHumaUserWebhook_DisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.WebhooksEnabled.Set(false) + defer config.WebhooksEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/webhooks", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_webhook_event_test.go b/pkg/webtests/huma_webhook_event_test.go new file mode 100644 index 000000000..6db2dbc5d --- /dev/null +++ b/pkg/webtests/huma_webhook_event_test.go @@ -0,0 +1,48 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaWebhookEvents covers the available-webhook-events listing. The route +// is only registered when webhooks are enabled (the test config default). +func TestHumaWebhookEvents(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Returns the events", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var events []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events)) + assert.ElementsMatch(t, models.GetAvailableWebhookEvents(), events) + }) + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/login_test.go b/pkg/webtests/login_test.go index f271e1727..17b0f9b07 100644 --- a/pkg/webtests/login_test.go +++ b/pkg/webtests/login_test.go @@ -68,6 +68,22 @@ func TestLogin(t *testing.T) { require.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeEmailNotConfirmed) }) + t.Run("disabled account", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ + "username": "user17", + "password": "12345678" +}`, nil, nil) + require.Error(t, err) + assertHandlerErrorCode(t, err, user.ErrCodeAccountDisabled) + }) + t.Run("locked account", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ + "username": "user18", + "password": "12345678" +}`, nil, nil) + require.Error(t, err) + assertHandlerErrorCode(t, err, user.ErrCodeAccountLocked) + }) } func TestLoginTOTPLockout(t *testing.T) { diff --git a/veans/go.mod b/veans/go.mod index c025994dc..88e1cfca7 100644 --- a/veans/go.mod +++ b/veans/go.mod @@ -3,9 +3,12 @@ module code.vikunja.io/veans go 1.25.0 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b github.com/magefile/mage v1.17.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/sahilm/fuzzy v0.1.2 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/sys v0.43.0 @@ -14,8 +17,24 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/veans/go.sum b/veans/go.sum index 3e0c9d612..5490b412a 100644 --- a/veans/go.sum +++ b/veans/go.sum @@ -1,3 +1,17 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= @@ -5,17 +19,40 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= +github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -24,14 +61,22 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go index 3a9381e6e..a1251be89 100644 --- a/veans/internal/bootstrap/bootstrap.go +++ b/veans/internal/bootstrap/bootstrap.go @@ -31,7 +31,6 @@ import ( "io" "os" "regexp" - "sort" "strconv" "strings" @@ -40,6 +39,7 @@ import ( "code.vikunja.io/veans/internal/config" "code.vikunja.io/veans/internal/credentials" "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/picker" "code.vikunja.io/veans/internal/status" ) @@ -388,44 +388,29 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte } active = append(active, pr) } - sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title }) - - // The "create a new project" option sits at len(active)+1 in the menu; - // when the user has nothing to pick from, it's the only choice. - createIdx := len(active) + 1 if len(active) == 0 { fmt.Fprintln(out, "No projects yet — let's create one.") return createProject(ctx, c, p, out) } - fmt.Fprintln(out, "Available projects:") - for i, pr := range active { - ident := pr.Identifier - if ident == "" { - ident = "(no identifier)" - } - fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident) - } - fmt.Fprintf(out, " [%d] Create a new project\n", createIdx) - - choice, err := p.ReadLine("Pick a project [1]: ") - if err != nil { + // picker.Pick reads os.Stdin directly via bubbletea. The prompter's + // buffered reader is idle here (all earlier prompts blocked at a + // newline in canonical mode), so there's no buffered input to lose; + // the terminal is restored to canonical mode when Pick returns. + res, err := picker.Pick(active) + switch { + case errors.Is(err, picker.ErrCanceled): + return nil, output.New(output.CodeValidation, "project selection canceled") + case errors.Is(err, picker.ErrNotATerminal): + return nil, output.New(output.CodeValidation, "not a terminal — pass --project ") + case err != nil: return nil, err } - choice = strings.TrimSpace(choice) - idx := 1 - if choice != "" { - v, err := strconv.Atoi(choice) - if err != nil || v < 1 || v > createIdx { - return nil, output.New(output.CodeValidation, "invalid project choice %q", choice) - } - idx = v - } - if idx == createIdx { + if res.CreateNew { return createProject(ctx, c, p, out) } - return active[idx-1], nil + return res.Project, nil } // createProject prompts for the new project's title and identifier and diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go index e661f5bc2..edcabc766 100644 --- a/veans/internal/client/types.go +++ b/veans/internal/client/types.go @@ -46,11 +46,13 @@ type BotUserCreate struct { // Project mirrors pkg/models/project.Project. type Project struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Identifier string `json:"identifier,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + ParentProjectID int64 `json:"parent_project_id,omitempty"` + Position float64 `json:"position,omitempty"` } // ProjectView is a saved view (Kanban/List/Gantt/Table) on a project. diff --git a/veans/internal/picker/flatten.go b/veans/internal/picker/flatten.go new file mode 100644 index 000000000..bb9561a4a --- /dev/null +++ b/veans/internal/picker/flatten.go @@ -0,0 +1,118 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "unicode/utf8" + + "code.vikunja.io/veans/internal/client" + "github.com/sahilm/fuzzy" +) + +// row is one visible line in the picker. matches holds rune indexes into the +// title for highlighting; dimmed rows are kept only as context for a matching +// descendant and are skipped by the cursor. +type row struct { + project *client.Project + depth int + dimmed bool + matches []int +} + +// flatten walks the forest depth-first into a render list. An empty query +// returns every node undimmed. A non-empty query fuzzy-matches each title +// (case-insensitive, via sahilm/fuzzy) and keeps a node iff it matches or any +// descendant is kept; a kept-but-non-matching node is dimmed context. +func flatten(forest []*node, query string) []row { + if query == "" { + var rows []row + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + rows = append(rows, row{project: n.project, depth: n.depth}) + walk(n.children) + } + } + walk(forest) + return rows + } + + var rows []row + var walk func(n *node) bool + walk = func(n *node) bool { + matches, matched := matchTitle(query, n.project.Title) + + start := len(rows) + rows = append(rows, row{}) // placeholder; finalized only if kept + + descendantKept := false + for _, c := range n.children { + if walk(c) { + descendantKept = true + } + } + + if !matched && !descendantKept { + rows = rows[:start] + return false + } + rows[start] = row{ + project: n.project, + depth: n.depth, + dimmed: !matched, + matches: matches, + } + return true + } + + for _, n := range forest { + walk(n) + } + return rows +} + +// matchTitle reports whether title fuzzy-matches query and, if so, the rune +// indexes of the matched characters. sahilm/fuzzy reports byte indexes, so we +// translate them to rune offsets for correct highlighting of multibyte titles. +func matchTitle(query, title string) (runeMatches []int, matched bool) { + results := fuzzy.Find(query, []string{title}) + if len(results) == 0 { + return nil, false + } + return byteToRuneIndexes(title, results[0].MatchedIndexes), true +} + +func byteToRuneIndexes(s string, byteIdx []int) []int { + if len(byteIdx) == 0 { + return nil + } + want := make(map[int]bool, len(byteIdx)) + for _, b := range byteIdx { + want[b] = true + } + out := make([]int, 0, len(byteIdx)) + runePos := 0 + for b := 0; b < len(s); { + if want[b] { + out = append(out, runePos) + } + _, size := utf8.DecodeRuneInString(s[b:]) + b += size + runePos++ + } + return out +} diff --git a/veans/internal/picker/flatten_test.go b/veans/internal/picker/flatten_test.go new file mode 100644 index 000000000..fe60411fb --- /dev/null +++ b/veans/internal/picker/flatten_test.go @@ -0,0 +1,122 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func sampleForest() []*node { + return buildForest([]*client.Project{ + proj(1, 0, 1, "Backend"), + proj(2, 1, 1, "Frontend"), + proj(3, 1, 2, "Database"), + proj(4, 0, 2, "Marketing"), + }) +} + +func rowTitles(rows []row) []string { + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.project.Title + } + return out +} + +func TestFlatten_EmptyQuery(t *testing.T) { + rows := flatten(sampleForest(), "") + wantTitles := []string{"Backend", "Frontend", "Database", "Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + wantDepths := []int{0, 1, 1, 0} + for i, r := range rows { + if r.depth != wantDepths[i] { + t.Errorf("row %d depth = %d, want %d", i, r.depth, wantDepths[i]) + } + if r.dimmed { + t.Errorf("row %d should not be dimmed on empty query", i) + } + if r.matches != nil { + t.Errorf("row %d should have nil matches on empty query", i) + } + } +} + +func TestFlatten_DeepChildSurfacesDimmedAncestor(t *testing.T) { + // "Frontend" is a child of "Backend"; matching it must keep "Backend" + // as a dimmed context row. + rows := flatten(sampleForest(), "frontend") + wantTitles := []string{"Backend", "Frontend"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + if !rows[0].dimmed { + t.Error("ancestor Backend should be dimmed (context only)") + } + if rows[1].dimmed { + t.Error("matching Frontend should not be dimmed") + } +} + +func TestFlatten_MatchingNodeCarriesMatchIndexes(t *testing.T) { + rows := flatten(sampleForest(), "front") + var frontend *row + for i := range rows { + if rows[i].project.Title == "Frontend" { + frontend = &rows[i] + } + } + if frontend == nil { + t.Fatal("Frontend row missing") + } + // "front" should match the leading runes of "Frontend". + want := []int{0, 1, 2, 3, 4} + if !reflect.DeepEqual(frontend.matches, want) { + t.Fatalf("matches: got %v, want %v", frontend.matches, want) + } +} + +func TestFlatten_NonMatchingSiblingsDropped(t *testing.T) { + // Matching "Marketing" must not pull in "Backend"/"Frontend"/"Database". + rows := flatten(sampleForest(), "marketing") + wantTitles := []string{"Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } +} + +func TestFlatten_NoMatchYieldsEmpty(t *testing.T) { + rows := flatten(sampleForest(), "zzzzz") + if len(rows) != 0 { + t.Fatalf("expected no rows, got %v", rowTitles(rows)) + } +} + +func TestFlatten_CaseInsensitive(t *testing.T) { + lower := flatten(sampleForest(), "backend") + upper := flatten(sampleForest(), "BACKEND") + if !reflect.DeepEqual(rowTitles(lower), rowTitles(upper)) { + t.Fatalf("case sensitivity differs: %v vs %v", rowTitles(lower), rowTitles(upper)) + } + if len(lower) == 0 { + t.Fatal("expected at least one match for 'backend'") + } +} diff --git a/veans/internal/picker/model.go b/veans/internal/picker/model.go new file mode 100644 index 000000000..4139d0f14 --- /dev/null +++ b/veans/internal/picker/model.go @@ -0,0 +1,238 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "fmt" + "strings" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const maxVisibleRows = 12 + +var ( + dimStyle = lipgloss.NewStyle().Faint(true) + matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + cursorMark = "❯" +) + +// model is the bubbletea state for the picker. The pinned "create a new +// project" entry is the trailing row with a nil project; it is always +// selectable and never filtered out. +type model struct { + forest []*node + query string + rows []row + cursor int // index into rows, always on a selectable row + offset int // first visible row index + + result *client.Project + createNew bool + canceled bool +} + +func newModel(forest []*node) *model { + m := &model{forest: forest} + m.recompute() + return m +} + +func (m *model) recompute() { + rows := flatten(m.forest, m.query) + rows = append(rows, row{project: nil}) // pinned create row + m.rows = rows + // recompute only runs when the query changes (or on init), so snap to the + // first match. Keeping the old cursor could leave it on the trailing create + // row after the list narrows, making Enter create a project instead of + // picking the visible match. + m.cursor = 0 + m.offset = 0 + m.clampCursor() + m.ensureVisible() +} + +func (r row) isCreate() bool { return r.project == nil } + +func (r row) selectable() bool { return r.isCreate() || !r.dimmed } + +func (m *model) clampCursor() { + if m.cursor >= len(m.rows) { + m.cursor = len(m.rows) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.rows[m.cursor].selectable() { + return + } + // Snap to the nearest selectable row, preferring downward. + for i := m.cursor; i < len(m.rows); i++ { + if m.rows[i].selectable() { + m.cursor = i + return + } + } + for i := m.cursor; i >= 0; i-- { + if m.rows[i].selectable() { + m.cursor = i + return + } + } +} + +func (m *model) moveCursor(delta int) { + i := m.cursor + for { + i += delta + if i < 0 || i >= len(m.rows) { + return + } + if m.rows[i].selectable() { + m.cursor = i + m.ensureVisible() + return + } + } +} + +func (m *model) ensureVisible() { + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+maxVisibleRows { + m.offset = m.cursor - maxVisibleRows + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m *model) Init() tea.Cmd { return nil } + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "enter": + sel := m.rows[m.cursor] + if sel.isCreate() { + m.createNew = true + } else { + m.result = sel.project + } + return m, tea.Quit + case "up": + m.moveCursor(-1) + case "down": + m.moveCursor(1) + case "backspace": + if m.query != "" { + r := []rune(m.query) + m.query = string(r[:len(r)-1]) + m.recompute() + } + default: + // Treat printable runes and space as query input. + if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { + runes := key.Runes + // KeySpace is not guaranteed to populate key.Runes; substitute a + // literal space so multi-word fuzzy queries still work. + if key.Type == tea.KeySpace && len(runes) == 0 { + runes = []rune{' '} + } + m.query += string(runes) + m.recompute() + } + } + return m, nil +} + +func (m *model) View() string { + var b strings.Builder + fmt.Fprintf(&b, "> %s\n", m.query) + + end := min(m.offset+maxVisibleRows, len(m.rows)) + for i := m.offset; i < end; i++ { + b.WriteString(m.renderRow(i)) + b.WriteByte('\n') + } + + fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows)) + return b.String() +} + +func (m *model) renderRow(i int) string { + r := m.rows[i] + + marker := " " + if i == m.cursor { + marker = cursorMark + " " + } + + indent := strings.Repeat(" ", r.depth) + + var label string + switch { + case r.isCreate(): + label = "Create a new project" + case r.dimmed: + label = dimStyle.Render(r.project.Title + projectSuffix(r.project)) + default: + label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project)) + } + + return marker + indent + label +} + +// projectSuffix is the dimmed metadata appended to a project row. Titles aren't +// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled +// projects distinguishable during init. +func projectSuffix(p *client.Project) string { + s := fmt.Sprintf(" #%d", p.ID) + if p.Identifier != "" { + s += " " + p.Identifier + } + return s +} + +// highlight bolds the matched runes of title. matches are rune indexes. +func highlight(title string, matches []int) string { + if len(matches) == 0 { + return title + } + matchSet := make(map[int]bool, len(matches)) + for _, idx := range matches { + matchSet[idx] = true + } + var b strings.Builder + for i, r := range []rune(title) { + if matchSet[i] { + b.WriteString(matchStyle.Render(string(r))) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/veans/internal/picker/picker.go b/veans/internal/picker/picker.go new file mode 100644 index 000000000..ee373e1a4 --- /dev/null +++ b/veans/internal/picker/picker.go @@ -0,0 +1,71 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "errors" + "fmt" + "os" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +// Result is what the user chose: an existing project or the create-new action. +type Result struct { + Project *client.Project + CreateNew bool +} + +var ( + // ErrCanceled is returned when the user dismisses the picker (Esc / Ctrl-C). + ErrCanceled = errors.New("selection canceled") + // ErrNotATerminal is returned when stdin is not a TTY, so the interactive + // picker can't run — callers should fall back to `--project `. + ErrNotATerminal = errors.New("not a terminal") +) + +// Pick runs the interactive project picker over projects and returns the +// user's choice. Output is written to stderr (prompts go to stderr by +// convention) and the terminal is left in canonical mode on exit. +func Pick(projects []*client.Project) (Result, error) { + // The picker reads stdin and draws to stderr; both must be a TTY, else it + // would run invisibly (e.g. stderr redirected to a file) and look hung. + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return Result{}, ErrNotATerminal + } + + m := newModel(buildForest(projects)) + prog := tea.NewProgram(m, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr)) + final, err := prog.Run() + if err != nil { + return Result{}, fmt.Errorf("run project picker: %w", err) + } + + fm, ok := final.(*model) + if !ok { + return Result{}, fmt.Errorf("project picker returned unexpected model type %T", final) + } + if fm.canceled { + return Result{}, ErrCanceled + } + if fm.createNew { + return Result{CreateNew: true}, nil + } + return Result{Project: fm.result}, nil +} diff --git a/veans/internal/picker/tree.go b/veans/internal/picker/tree.go new file mode 100644 index 000000000..1835badea --- /dev/null +++ b/veans/internal/picker/tree.go @@ -0,0 +1,78 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package picker renders an interactive, hierarchical, fuzzy-searchable +// project picker for `veans init`. The pure tree/flatten logic is split from +// the bubbletea TUI so it stays unit-testable. +package picker + +import ( + "sort" + + "code.vikunja.io/veans/internal/client" +) + +type node struct { + project *client.Project + depth int + children []*node +} + +// buildForest turns a flat project slice into a depth-annotated forest. A +// project whose ParentProjectID is absent from the input becomes a root — +// this mirrors the frontend's effective-parent behavior so children of a +// hidden or archived parent don't vanish. Siblings are ordered by Position, +// tie-broken by Title. +func buildForest(projects []*client.Project) []*node { + byID := make(map[int64]*node, len(projects)) + for _, p := range projects { + if p == nil { + continue + } + byID[p.ID] = &node{project: p} + } + + var roots []*node + for _, p := range projects { + if p == nil { + continue + } + n := byID[p.ID] + parent, ok := byID[p.ParentProjectID] + if p.ParentProjectID == 0 || !ok { + roots = append(roots, n) + continue + } + parent.children = append(parent.children, n) + } + + sortAndAssignDepth(roots, 0) + return roots +} + +func sortAndAssignDepth(nodes []*node, depth int) { + sort.SliceStable(nodes, func(i, j int) bool { + a, b := nodes[i].project, nodes[j].project + if a.Position != b.Position { + return a.Position < b.Position + } + return a.Title < b.Title + }) + for _, n := range nodes { + n.depth = depth + sortAndAssignDepth(n.children, depth+1) + } +} diff --git a/veans/internal/picker/tree_test.go b/veans/internal/picker/tree_test.go new file mode 100644 index 000000000..d2874f949 --- /dev/null +++ b/veans/internal/picker/tree_test.go @@ -0,0 +1,129 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "strconv" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func proj(id, parent int64, pos float64, title string) *client.Project { + return &client.Project{ID: id, ParentProjectID: parent, Position: pos, Title: title} +} + +// titlesWithDepth flattens a forest depth-first into "title@depth" tokens. +func titlesWithDepth(forest []*node) []string { + var out []string + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + out = append(out, n.project.Title+"@"+strconv.Itoa(n.depth)) + walk(n.children) + } + } + walk(forest) + return out +} + +func TestBuildForest_SingleRoot(t *testing.T) { + forest := buildForest([]*client.Project{proj(1, 0, 1, "Root")}) + got := titlesWithDepth(forest) + want := []string{"Root@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_Nested(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 1, 1, "Child"), + proj(3, 2, 1, "Grandchild"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Child@1", "Grandchild@2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_MultipleRoots(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 2, "Beta"), + proj(2, 0, 1, "Alpha"), + }) + got := titlesWithDepth(forest) + // Roots are sorted by position: Alpha (pos 1) before Beta (pos 2). + want := []string{"Alpha@0", "Beta@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_SiblingOrderPositionThenTitle(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 0, "Root"), + proj(2, 1, 2, "C"), + proj(3, 1, 1, "B"), + // same position as B — tie-break by title puts A before B. + proj(4, 1, 1, "A"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "A@1", "B@1", "C@1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_OrphanBecomesRoot(t *testing.T) { + // Parent 99 is not in the input set — child should surface as a root. + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 99, 2, "Orphan"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Orphan@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_DepthCorrectness(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "A"), + proj(2, 1, 1, "B"), + proj(3, 2, 1, "C"), + proj(4, 3, 1, "D"), + }) + depthOf := map[string]int{} + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + depthOf[n.project.Title] = n.depth + walk(n.children) + } + } + walk(forest) + for title, want := range map[string]int{"A": 0, "B": 1, "C": 2, "D": 3} { + if depthOf[title] != want { + t.Errorf("depth of %q = %d, want %d", title, depthOf[title], want) + } + } +}