diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml deleted file mode 100644 index 5d3ad1a52..000000000 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Feature Request -description: Found something you weren't expecting? Report it here! -type: Feature -body: - - type: markdown - attributes: - value: | - NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue. [More information about our security policy](https://vikunja.io/contact/#security). - - type: markdown - attributes: - value: | - Please fill out this issue template to request a new feature. - - 1. If you want to report a bug, please use the Bug template. - 2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io). - 3. Make sure you are using the latest release and take a moment to check that your feature hasn't been requested before. - 4. Please include all relevant information in the feature request to allow users to discuss this fully. - - type: checkboxes - id: searched - attributes: - label: Pre-submission checklist - options: - - label: I have searched for existing open or closed issue reports with the same feature request. - required: true - - type: textarea - id: description - attributes: - label: Description - description: | - Please provide a description of the feature you are looking for. - - type: textarea - id: alternatives - attributes: - label: Which alternatives did you consider using instead? - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7e854696..14e3234ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ env: on: pull_request: + merge_group: push: tags: - v* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e1106387..f89ba5ee3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,16 @@ jobs: test: - feature - web + - e2e-api + exclude: + - db: sqlite + test: e2e-api + - db: postgres + test: e2e-api + - db: mysql + test: e2e-api + - db: paradedb + test: e2e-api services: db-mysql: image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }} @@ -194,7 +204,7 @@ jobs: ports: - 5432:5432 db-paradedb: - image: ${{ matrix.db == 'paradedb' && 'paradedb/paradedb:latest-pg17@sha256:741010eaa8894d292203d9407d46fc95ee4d0cd587915513bf92e6bd70cbd65e' || '' }} + image: ${{ matrix.db == 'paradedb' && 'paradedb/paradedb:latest-pg17@sha256:5a60852994cb0663ed9cdb04796a487605f8b99266e3ad5057f10e09e1aa019d' || '' }} env: POSTGRES_PASSWORD: vikunjatest POSTGRES_DB: vikunjatest diff --git a/.golangci.yml b/.golangci.yml index d4afbf288..28f46f670 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,8 @@ version: "2" run: tests: true + build-tags: + - mage linters: enable: - asasalint @@ -146,6 +148,10 @@ linters: - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' + - linters: + - err113 + path: magefile.go + text: 'do not define dynamic errors, use wrapped static errors instead:' paths: - third_party$ - builtin$ diff --git a/CHANGELOG.md b/CHANGELOG.md index f9da1371c..1b7ae0e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,258 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). All releases can be found on https://code.vikunja.io/vikunja/releases. +## [2.2.0] - 2026-03-20 + +### Bug Fixes + +* *(attachments)* Sync kanban store and task ref on attachment changes +* *(auth)* Use SameSite=None for refresh token cookie to fix desktop app +* *(auth)* Make SameSite=None conditional on HTTPS for refresh cookie +* *(caldav)* Eliminate nested db session in CalDAV auth +* *(caldav)* Parse timestamps in configured timezone +* *(caldav)* Use /dav/projects/ as home to make iOS/MacOS reminders work (#2417) +* *(ci)* Remove HTML comments inside table that break markdown rendering +* *(cli)* Make user deletion confirmation check Windows compatible (#2339) +* *(db)* Prevent SQLite "database is locked" errors under concurrent writes +* *(db)* Use immediate txlock for SQLite instead of MaxOpenConns(1) +* *(db)* Use WAL mode for SQLite and temp file for ephemeral databases +* *(desktop)* Disable nodeIntegration and enable contextIsolation/sandbox +* *(desktop)* Validate URL schemes before shell.openExternal +* *(desktop)* Block same-window navigation to external origins +* *(docker)* Remove COPY for deleted patches directory +* *(e2e)* Drain event handlers and stop browser between tests +* *(events)* Defer task event dispatch until after transaction commit +* *(events)* Defer event dispatch for task sub-entities +* *(events)* Defer event dispatch for project operations +* *(events)* Defer event dispatch for team operations +* *(events)* Defer event dispatch for user creation and task positions +* *(events)* Dispatch pending events in CalDAV handlers after commit +* *(events)* Dispatch pending events in migration and export handlers +* *(frontend)* Add horizontal overflow handling to tables on mobile +* *(frontend)* Use semantic class instead of targeting Tailwind utility +* *(frontend)* Use mbs-2 utility class instead of scoped CSS +* *(gantt)* Always show relation arrows and fix arrow Y positioning +* *(gantt)* Update relation arrows in real-time during drag and resize +* *(gantt)* Make relation arrows smaller and dash precedes lines +* *(gantt)* Spread overlapping relation arrows at shared endpoints +* *(gantt)* Improve parent task bar styling and visual grouping +* *(gantt)* Make collapse/expand triangle smaller +* *(gantt)* Move parent diamonds outward with stroke and remove hover effect +* *(gantt)* Only set hasDerivedDates when children have actual dates +* *(gantt)* Clamp collapse chevron x position to prevent negative offset +* *(gantt)* Remove unreachable hover rule on relation arrows +* *(gantt)* Render collapse chevron after bars for correct SVG paint order +* *(menu)* Prevent dropdown from closing when cursor crosses offset gap (#2367) +* *(menu)* Show all project menu items in sidebar dropdown +* *(migration)* Support space-separated date format in TickTick importer +* *(nav)* Project drag handle position +* *(shortcuts)* Resolve lint errors in shortcut module +* *(shortcuts)* Track active sequences explicitly to prevent misfires +* *(tasks)* Support both expand and expand[] query parameter formats (#2415) +* *(test)* Update mobile kanban test to use close button instead of back button +* *(views)* Assign default position when creating new project views +* Use MinPositionSpacing threshold in calculateNewPositionForTask (#2320) ([3ca4913](3ca4913fcb6dc287adec552dd62024a3b63f477a)) +* Remove invalidateAvatarCache call that broke request deduplication (#2317) ([7297682](7297682cadae3e2c48f2a09d20a6191b561c1eeb)) +* Add /tmp directory to Docker image to fix data export ([84d563c](84d563c51b6cd15000f4af6e058362c5e45c8dc2)) +* Update old kolaente.dev URLs to code.vikunja.io (#2342) ([a160048](a160048cc3259773405654746117bf6dc0565eee)) +* Validate default settings timezone on startup (#2345) ([40bcf2b](40bcf2b36f777c6338a40581a472333974770c93)) +* Correct package.json indentation after dependency removal ([f8763d8](f8763d812e2a7c7f9b2d28ff3e502693419f859e)) +* Remove duplicate close button on mobile task detail view ([8a4f3a9](8a4f3a916f2eae71f0106c42d257b5ee4dc77928)) +* Prevent nil pointer panic in mention notification listeners ([18f1687](18f16878a84952cf5d0ddb583385dc340d1f5ff3)) +* Only drop Vikunja-owned tables in WipeEverything ([14e2c95](14e2c95a830eb4206390a58f85b4bc49068f23cd)) +* Only dump Vikunja-owned tables ([cd7d405](cd7d40583aaa43e1d9445e9f54ea81d14eb12232)) +* Remove debug log statements from task duplicate ([6da0f68](6da0f685624c66806027070d537648be9b100e29)) +* Close source file handle when duplicating attachments ([7aad96b](7aad96b1991a981245cc119bce189de327ea36ce)) +* Preserve cover image when duplicating task ([9c23e19](9c23e196440830d0b94ca18bfb1002a0db27b54c)) +* Allow browser caching for file downloads (#2349) ([54d9775](54d977532e9e9a99281bc56965583d07f3913b21)) +* Handle deleted user in saved filter view event listener ([7288483](72884838790db52852c8643ab17be5f6fc0067f0)) +* Include remote IP address in HTTP request logs ([f9cb0a2](f9cb0a2de1d7ed64aa04f74f4209f117ea60186f)) +* Use ParadeDB v2 fuzzy prefix matching for search (#2346) ([0a38ec0](0a38ec08388c9d2716f9e41185af0bcfb0ed7f8d)) +* Prefer working directory for service.rootpath default ([d3cbc4f](d3cbc4fc4fb7d7fe054c4c022656f2b4d5c42bde)) +* Ensure /tmp is writable by container user in Docker image ([f497e8b](f497e8bb6d78f3b01c2a87540e28d7727e17676e)) +* Remove debounce from color picker to prevent stale color on save ([d196af0](d196af0503053d00e05afb8d2585a67b229a5144)) +* Send account deletion notification before deleting user row ([79a612a](79a612aa5d95f89cd84148295146a92ccddefa74)) +* Register bulk label route correctly for API token permissions ([e19bea8](e19bea8e3a2804485479748b1c91dc58719dbe11)) +* Prevent authenticated UI flash when server rejects JWT session (#2387) ([28cc9e0](28cc9e0571c98bb04d216e5fe47aaa503a1e887b)) +* Preserve CalDAV inverse relations when parent has no RELATED-TO (#2389) ([ada2eba](ada2ebab9e1738bb145db1c498d2dda84d11c10b)) +* Collapse view buttons into dropdown when overflowing (#2306) ([7b6b432](7b6b4323015239098a55adcb134d12dc9785f5cb)) +* Invalidate all sessions when enabling TOTP ([3bc0093](3bc009368628fb286632b456f9bf2d575a8bfa43)) +* Make mage fmt skip gitignored files ([e74265d](e74265d921b9b12bf89882e791743758b42f5f3d)) +* Ensure frontend dist directory exists for lint and fmt commands ([c62b7e6](c62b7e680f82253d89f8cefbfe4bb4b4bb64c5e9)) +* Handle S3 backend in user export download ([b0ede53](b0ede53c051d45a3e861450187e64c5342be5362)) +* Use file mime type instead of hardcoded application/zip in S3 export ([4cd63f9](4cd63f93a48d784dd2566c26a0642ec0c69d3d8f)) +* Configure Echo IPExtractor to prevent rate limit bypass via spoofed headers ([a498dd6](a498dd69915a006c07e9d82660a2185d7e8136ee)) +* Block login for StatusAccountLocked users ([4c80932](4c80932b6475ad54a2e2a81541d89a3b8471a762)) +* Prevent password reset from re-enabling admin-disabled accounts ([d8570c6](d8570c603da1f26635ce6048d6af85ede827abfb)) +* Reject password reset token requests for disabled users ([708ccab](708ccab895a23ed59b330db4a58a441bf5fbfcb2)) +* Prevent email confirmation from re-enabling admin-disabled accounts ([049f4a6](049f4a6be46f9460bd516f489ef9f569574bc70d)) +* Update test expectations for new disabled user fixture ([89923eb](89923ebe7090038c57ee3ad23eca86858c9c2eca)) +* Reject images exceeding 50M pixels before decode ([af61d0f](af61d0f1a0d6e9394546d2d64dff043cfbe641f7)) +* Adapt image preview DoS protection to new FileStorage interface ([be0aaa7](be0aaa70601af919f68fa1153f76bcf6335bc0b5)) +* Verify comment belongs to task in URL to prevent IDOR ([bc6d843](bc6d843ed4df82a6c89f10aa676a7a33d27bf2fd)) +* Require CanUpdate for project background deletion ([f066eb3](f066eb3ea4d1648ef925a745836e48a71b600a5f)) +* Only enforce task_id check when TaskID is provided ([4941961](49419619bd0052bdd7e727404a9284acd928a903)) +* Use require.Error instead of assert.Error for error assertions ([b7a1408](b7a14080983d2781e1428be9b77fae319e7788e4)) +* Reject CalDAV basic auth when TOTP is enabled ([cdf5d30](cdf5d30a425d032f749b78b98b828f25ad882615)) +* Use user10 instead of user1 for TOTP fixture to avoid breaking login tests ([659e73a](659e73af05af154dda315d025e8b3a12705e4a7e)) +* Update TOTP fixtures and tests to avoid conflicts with existing enrollment tests ([1ed813c](1ed813caf00224d90c3c89c5b8078788f5730f51)) + +### Dependencies + +* *(deps)* Update dev-dependencies +* *(deps)* Upgrade serialize-javascript to 7.0.3 +* *(deps)* Update dependency @vue/tsconfig to v0.9.0 +* *(deps)* Use forked afero-s3 to fix S3 read performance regression (#2313) +* *(deps)* Update dependency flexsearch to v0.8.212 +* *(deps)* Remove obsolete flexsearch 0.7.43 patch +* *(deps)* Remove @github/hotkey dependency +* *(deps)* Update dependency rollup-plugin-visualizer to v6.0.11 +* *(deps)* Update dependency electron to v40.7.0 +* *(deps)* Update immutable to 5.1.5 +* *(deps)* Update svgo to 3.3.3 +* *(deps)* Update tar to 7.5.10 and @tootallnate/once to 3.0.1 in desktop +* *(deps)* Update dependency vite-svg-loader to v5.1.1 +* *(deps)* Bump dompurify from 3.3.1 to 3.3.2 in /frontend +* *(deps)* Update dependency eslint to v9.39.4 +* *(deps)* Update dev-dependencies to v8.57.0 +* *(deps)* Update dependency sass-embedded to v1.98.0 +* *(deps)* Update dev-dependencies (#2395) +* *(deps)* Update dependency caniuse-lite to v1.0.30001779 +* *(deps)* Override flatted to 3.4.1 to fix unbounded recursion DoS +* *(deps)* Update tar override to 7.5.11 to fix symlink path traversal +* *(deps)* Update dependency vue-tsc to v3.2.6 +* *(deps)* Update dependency electron to v40.8.3 +* *(deps)* Update dev-dependencies to v4.2.2 +* *(deps)* Add daenney/ssrf for webhook SSRF protection +* *(deps)* Update dependency stylelint to v17.5.0 + +### Documentation + +* Update user search endpoint description for external team bypass ([b5086fe](b5086febc71a80467302584b9d41e10459d9d77e)) +* Update rootpath description to mention working directory default ([ddfc565](ddfc565c614761d3dda037902c8309bf5a27fdd1)) +* Document database.schema config option for PostgreSQL ([8868b21](8868b214ca2f0b34a6506066af1c4c96e13ca40d)) +* Document IP extraction and trusted proxy config options ([015a172](015a172c2a07d3fc3827645d9e1bfe986ee58a03)) + +### Features + +* *(ci)* Post preview deployment comment on PRs +* *(ci)* Enable merge queue trigger +* *(config)* Add webhooks.allownonroutableips setting +* *(events)* Add DispatchOnCommit/DispatchPending for deferred event dispatch +* *(frontend)* Upgrade Tailwind CSS from v3 to v4 +* *(frontend)* Highlight overdue tasks consistently (#958) +* *(gantt)* Add expand=subtasks to Gantt API params +* *(gantt)* Add task tree builder utility for hierarchy +* *(gantt)* Add dependency arrow data builder +* *(gantt)* Integrate task tree into Gantt rendering with collapse +* *(gantt)* Add collapse/expand chevron and indent indicators +* *(gantt)* Render parent summary bars with diamond endpoints +* *(gantt)* Create arrow SVG overlay component for relations +* *(gantt)* Wire relation arrows into GanttChart with toggle +* *(handlers)* Dispatch pending events after transaction commit +* *(release)* Update frontend package.json version on release +* *(shortcuts)* Add event.code-based shortcut module +* *(webhooks)* Add built-in SSRF protection using daenney/ssrf +* Ensure forms submit on Enter (#959) ([e1d1e7c](e1d1e7c848bb2f0062a5fa522c7a357a2d3c723f)) +* Use offical vite plugin for sentry (#873) ([0a9586e](0a9586e8d4351e47edacb63fa6667193d99ff7ee)) +* Mini tiptap improvements ([b92735b](b92735b0e907bf7613b106ea633b82efa7f1781a)) +* Surface API validation errors to registration form fields (#1902) ([c6f0d8b](c6f0d8babe6f36e6d25d22a932c9f0a075a5a359)) +* Add table registration to db package ([d26936f](d26936f869c8489b06b0d9377af489236765a9e1)) +* Register Vikunja tables with db package at init ([3dd2ba4](3dd2ba4aa4309b589e809621de2ecee89ee54159)) +* Add RegisteredTableNames helper to db package ([0a8534d](0a8534ded9fca162fb1721a86d835677b30f2cdb)) +* Add task duplicate backend model and tests ([d8f3a96](d8f3a96b06fc40d4b30954cc71a3bb43890f8cfc)) +* Register task duplicate API route ([77fdf1b](77fdf1b84b27f80f4f332a26e9d7cf1ad032f211)) +* Add task duplicate frontend model and service ([52bee37](52bee379d417d37b21b3d6f0cac8e67f83716925)) +* Add duplicateTask action to task store ([2014d50](2014d50b953f86fb5a66bf32c74035b8d42c2e7a)) +* Add duplicate button to task detail view ([6c9407c](6c9407c58f4ed01c0eac37aa51e7939cd5a11a1d)) +* Bypass discoverability settings for external team members ([28b913f](28b913f29f812ef51f3b8fe967d5560c1d8ed927)) +* Add InitEventsForTesting and Unfake for real event dispatch in tests ([1b1e8e5](1b1e8e5b19e9dd32a0d6089759d18c81883f8ffc)) +* Add mage test:e2e-api target for e2e API tests ([24b800d](24b800d48d27a90447bfb9765f23093e5b9bde41)) +* Add conversational email template and rendering ([d4b0302](d4b03026f0b98734a95e9cc22d3e77e89a7d3f4f)) +* Convert notifications to conversational email style ([b3572c5](b3572c5932ba9eb7159e48129c1e52f0333cf96e)) +* Add translation keys for conversational emails ([def73e2](def73e2f8eeadf807c9b2e2a422e2335444280dd)) +* Add user_id to webhooks and user-directed event infrastructure ([d4577c6](d4577c660f5550a59f1b90a2ef1f5fba49cb73c6)) +* Extend WebhookListener for user-level webhooks ([dbbc80a](dbbc80aea613779d43b015479fef0f7301d8e7e2)) +* Add API routes for user-level webhooks ([47a0775](47a0775c7378faf6c8b3af3cd1429d3be7c51e70)) +* Add user-level webhooks settings page ([2e1648e](2e1648ef4c7b1d1a05542567cd2a682f1038b03c)) +* Replace afero-s3 with minimal S3 afero.Fs implementation ([b065c62](b065c6200782bfd6e9eea889847e83f1dead906d)) +* Add service.ipextractionmethod and service.trustedproxies config options ([26324a7](26324a740a73d19748eea3c745c74f91f60cc86b)) +* Add StatusAccountLocked user status for TOTP lockouts ([f42a045](f42a045bdc175fbffee4f8ee9592fa8dfedbc8aa)) + +### Miscellaneous Tasks + +* *(dev)* Update devenv +* *(i18n)* Update translations via Crowdin +* Remove feature request issue template ([06ead58](06ead58ea3bb366970473d587db82bb36db07887)) + +### Other + +* *(other)* [skip ci] Updated swagger docs +* *(other)* Add e2e API tests to CI pipeline +* *(other)* Upgrade ParadeDB image to support v2 fuzzy search API + +### Refactor + +* *(attachments)* Read from task prop instead of global store +* *(attachments)* Return uploaded attachments instead of writing to store +* *(attachments)* Use local state instead of global attachment store +* *(attachments)* Remove global attachment store +* *(shortcuts)* Update directive to use new shortcut module +* *(shortcuts)* Update v-shortcut values to event.code format +* *(shortcuts)* Replace eventToHotkeyString with eventToShortcutString +* *(shortcuts)* Use event.code for raw keyboard handlers +* Batch label inserts during task duplication ([e07eeed](e07eeed21156ab2bdc6c02aceede9cbc91468a28)) +* Use TaskRelation.Create for copy relation ([692357a](692357a648367f1beb9ba192e3ed3425f8648893)) +* Move ListUsers tests from pkg/user to pkg/models ([54c7c4a](54c7c4aef2fbdf7d4c04630d75cd36a0d121daec)) +* Enable golangci-lint on magefile, fix errors ([cea8c78](cea8c7807d060e0a187c37c80ba42d02d4aa7637)) +* Fix contextcheck lint errors on magefile by passing mage context ([0a1104b](0a1104b75ce1a6fcadb0cd0678400cf3585a0eb1)) +* Merge last unique build tag "tools" into go.mod tools section ([1b5f3f4](1b5f3f4ccd15a954d1b3ac4fa49a99c2f299deff)) +* Add centralized ResolvePath for rootpath-relative paths ([2a7165a](2a7165aaba736c53be32bb8cf0cf77e6fb7cd501)) +* Use config.ResolvePath for all rootpath-relative paths ([a043940](a043940e14f686faa15339ecc06f91dd191d22d1)) +* Replace afero with FileStorage interface ([0e1f44e](0e1f44e57efe06d08a47d980fa49bdd260f5fac3)) +* Use StatusAccountLocked for TOTP lockouts ([7792bf6](7792bf6cea36ede6c38b9966f587222b476176cb)) +* Rename checkProjectBackgroundWriteRights to checkProjectBackgroundWritePermissions ([4b91e5e](4b91e5efa173c90346567d4b296ab6233a9cc093)) + +### Styling + +* Fix alignment in config key declarations ([ddd9ef5](ddd9ef5f2206dc5936cc14d359c70312806de233)) + +### Testing + +* *(shortcuts)* Add unit tests for shortcut parsing logic +* *(webhooks)* Add SSRF protection tests +* *(webhooks)* Allow non-routable IPs in E2E tests +* Update event assertions to work with deferred dispatch ([f516bbe](f516bbe560a7b2a0d348e71ecdab00229c5cf554)) +* Add web integration tests for task duplication ([4d494ba](4d494ba442b7bc6b4d7d06a3a3919f8d1bc6e066)) +* Add user 11 to external team 14 for discoverability tests ([64e455a](64e455a613134b74c5734570eef19f3631253738)) +* Add tests for external team user discoverability bypass ([3a73016](3a730165bc15f0fa2593aa8961e27192e93fcafb)) +* Verify email masking for external team name search ([0661789](06617891fafa7c73c1c7110d404cb0a76812842d)) +* Add e2e API test package with webhook pipeline verification ([1f3509b](1f3509bf27a9102ac96578d441d3731fb444dfa9)) +* Add fixture task with compound word for prefix search testing ([275f714](275f714224cc93f0f9cd7b4590ba2b07a79398e4)) +* Add web tests for prefix/substring search (#2346) ([892b38b](892b38b3b696e024e673dba3c0e302d5afa714fe)) +* Rewrite MultiFieldSearch tests with SQL output verification ([ee2723d](ee2723d9cf3c603bd22be9e5411d67f1c9f38799)) +* Call real MultiFieldSearch function and branch on db engine ([e6cbd67](e6cbd67ab52e92afadeaf0e9b3dbd96de3b3e1c1)) +* Add task #48 to expected results in feature tests ([3568aaa](3568aaacee6d102ec8b749409cb1c8ca73c096f8)) +* Adjust ParadeDB search tests for fuzzy prefix match broadening ([6268c48](6268c48f15955d812c6a569edb9c2d56e454fc27)) +* Fix lint and adjust project search test for ParadeDB fuzzy matching ([b69705e](b69705e64bc45b93a834f877936aea5a7886bd9a)) +* Add result count assertions for ParadeDB search tests ([c7c63e8](c7c63e8eadb174d163516590ec5c7ed945670cd5)) +* Fix non-ParadeDB project search count assertion ([df0e3a8](df0e3a84a9cdf94b8a3f581ab7bf1690d36a6fe9)) +* Fix ParadeDB project search count to 27 ([d36ac9d](d36ac9ddda5ddbc781a06017ee6d45ff2f8a45d8)) +* Add tests for conversational email system ([aacf650](aacf650ec2c2817447107043620989d1b4c72130)) +* Add e2e tests for user-level webhooks ([05cc65f](05cc65fe9e4fa448cda437d58480a9f3f19d69ed)) +* Add web tests for bulk label task endpoint ([675dfb3](675dfb3ea47dd882de7e49ab1b0ace79a5e8bb9b)) +* Add failing test for bulk label API token route registration ([554593c](554593cdb6bc0d31a1809c4b969b4fda9423edc3)) +* Add FileStat assertion to validate storage path in attachment test ([17eccd8](17eccd848fd8688cd18f5dd46d1beb2c6ce96442)) +* Add tests for disabled user password reset prevention ([241b0e8](241b0e80b6d9e91cda1f03a9e3a6368710d1fe36)) +* Add web test for disabled user password reset rejection ([2260d76](2260d763b56290fcf8bfe5a9acfdee1a4332a65e)) +* Add failing test for image preview with oversized dimensions ([f7592e2](f7592e2cfdc11fb06441007a4fb1d2ca5a2f1c5a)) +* Add failing test for task comment IDOR ([2da8925](2da89258e53068253dcf8ef17d4dad141dba7d31)) +* Add failing test for project background delete with read-only access ([f60f3af](f60f3af70b6d8258dd342a9ac15b71f48326e9af)) +* Add TOTP fixture data for user1 ([27ef92b](27ef92b9bf36f437b151df13f801a504e73bddc8)) +* Add failing test for CalDAV 2FA bypass via basic auth ([bda16e7](bda16e770fa76f212d15b1faec5c83f9046a0bb3)) +* Register totp fixture in test setup ([a66bda2](a66bda2f51d4f7df8d353066a100de2d8c0aab32)) +* Verify CalDAV token auth bypasses TOTP check ([1f2aef7](1f2aef776ccdd0ac1405fc8bcbb47084091d42eb)) + ## [2.1.0] - 2026-02-27 ### Bug Fixes diff --git a/Dockerfile b/Dockerfile index 4e49f33db..560303236 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,7 @@ WORKDIR /app/vikunja ENTRYPOINT [ "/app/vikunja/vikunja" ] EXPOSE 3456 -COPY --from=apibuilder /tmp /tmp +COPY --from=apibuilder --chown=1000:1000 /tmp /tmp USER 1000 diff --git a/README.md b/README.md index c506ccc12..7beee3d78 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml/badge.svg)](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE) -[![Install](https://img.shields.io/badge/download-v2.1.0-brightgreen.svg)](https://vikunja.io/docs/installing) +[![Install](https://img.shields.io/badge/download-v2.2.0-brightgreen.svg)](https://vikunja.io/docs/installing) [![Docker Pulls](https://img.shields.io/docker/pulls/vikunja/vikunja.svg)](https://hub.docker.com/r/vikunja/vikunja/) [![Swagger Docs](https://img.shields.io/badge/swagger-docs-brightgreen.svg)](https://try.vikunja.io/api/v1/docs) [![Go Report Card](https://goreportcard.com/badge/code.vikunja.io/api)](https://goreportcard.com/report/code.vikunja.io/api) diff --git a/config-raw.json b/config-raw.json index 35e9f1148..2c3d4e5e7 100644 --- a/config-raw.json +++ b/config-raw.json @@ -46,7 +46,7 @@ { "key": "rootpath", "default_value": "\u003crootpath\u003e", - "comment": "The base path on the file system where the binary and assets are.\nVikunja will also look in this path for a config file, so you could provide only this variable to point to a folder\nwith a config file which will then be used." + "comment": "The base path on the file system where Vikunja stores its data (database, files, logs, plugins).\nDefaults to the current working directory. When running as a systemd service, this respects the WorkingDirectory= setting.\nVikunja will also look in this path for a config file, so you could provide only this variable to point to a folder\nwith a config file which will then be used." }, { "key": "maxitemsperpage", @@ -147,6 +147,16 @@ "key": "enableopenidteamusersearch", "default_value": "false", "comment": "If enabled, users will only find other users who are part of an existing team when they are searching for a user by their partial name. The other existing team may be created from openid. It is still possible to add users to teams with their exact email address even when this is enabled." + }, + { + "key": "ipextractionmethod", + "default_value": "direct", + "comment": "Method for extracting client IP addresses. 'direct' (default) uses the TCP remote address and ignores forwarding headers — use this when Vikunja faces the internet directly. 'xff' extracts from the X-Forwarded-For header — use this behind proxies like nginx, Traefik, or cloud load balancers. 'realip' extracts from the X-Real-IP header. When using 'xff' or 'realip', configure 'service.trustedproxies' with your proxy CIDR ranges." + }, + { + "key": "trustedproxies", + "default_value": "", + "comment": "Comma-separated list of CIDR ranges for trusted reverse proxies. Only used when service.ipextractionmethod is 'xff' or 'realip'. X-Forwarded-For / X-Real-IP headers are only trusted from these addresses. Example: '127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12'" } ] }, @@ -247,6 +257,11 @@ "key": "tls", "default_value": "false", "comment": "Enable SSL/TLS for mysql connections. Options: false, true, skip-verify, preferred" + }, + { + "key": "schema", + "default_value": "public", + "comment": "The PostgreSQL schema to use. Only used with postgres. If you have an existing Vikunja installation where the tables were created in a non-public schema (e.g. via the database user's search_path), you must set this to match that schema name." } ] }, @@ -969,6 +984,11 @@ "key": "proxypassword", "default_value": "", "comment": "The proxy password to use when authenticating against the proxy." + }, + { + "key": "allownonroutableips", + "default_value": "false", + "comment": "If set to true, webhook target URLs may resolve to non-globally-routable IP addresses (private networks, loopback, link-local, etc). When false (the default), Vikunja blocks outgoing webhook requests to these addresses to prevent SSRF attacks. Set this to true if you need webhooks to reach services on your internal network." } ] }, diff --git a/desktop/main.js b/desktop/main.js index 27c107620..f9698ff70 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -12,14 +12,43 @@ function createWindow() { width: 1680, height: 960, webPreferences: { - nodeIntegration: true, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webviewTag: false, + navigateOnDragDrop: false, } }) - // Open external links in the browser + // Open external links in the browser, but only allow protocols + // that the TipTap editor also allows (see frontend/src/components/input/editor/TipTap.vue). + // TipTap allows: http, https (built-in) + ftp, git, obsidian, notion, message + // We also allow mailto since it's a standard safe protocol for email links. mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; + try { + const parsedUrl = new URL(url); + const allowedProtocols = [ + 'http:', 'https:', 'mailto:', + 'ftp:', 'git:', 'obsidian:', 'notion:', 'message:', + ]; + if (allowedProtocols.includes(parsedUrl.protocol)) { + shell.openExternal(url); + } + } catch { + // Invalid URL, ignore silently + } + return { action: 'deny' }; + }); + + // Prevent same-window navigation to external origins. + // Only allow navigation to the local express server. + mainWindow.webContents.on('will-navigate', (event, navigationUrl) => { + const parsedUrl = new URL(navigationUrl); + // Allow navigations to the local express server + if (parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost') { + return; + } + event.preventDefault(); }); // Hide the toolbar diff --git a/desktop/package.json b/desktop/package.json index 6437d9dc4..a19cf1233 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -52,7 +52,7 @@ } }, "devDependencies": { - "electron": "40.7.0", + "electron": "40.8.3", "electron-builder": "26.8.1", "unzipper": "0.12.3" }, @@ -64,7 +64,9 @@ "electron" ], "overrides": { - "minimatch": "^10.2.3" + "minimatch": "^10.2.3", + "tar": "^7.5.11", + "@tootallnate/once": "^3.0.1" } } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 8c6a410b5..bbe5031dc 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: minimatch: ^10.2.3 + tar: ^7.5.11 + '@tootallnate/once': ^3.0.1 importers: @@ -16,8 +18,8 @@ importers: version: 5.2.1 devDependencies: electron: - specifier: 40.7.0 - version: 40.7.0 + specifier: 40.8.3 + version: 40.8.3 electron-builder: specifier: 26.8.1 version: 26.8.1(electron-builder-squirrel-windows@24.13.3) @@ -122,8 +124,8 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@3.0.1': + resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==} engines: {node: '>= 10'} '@types/cacheable-request@6.0.3': @@ -343,10 +345,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -554,8 +552,8 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} - electron@40.7.0: - resolution: {integrity: sha512-oQe76S/3V1rcb0+i45hAxnCH8udkRZSaHUNwglzNAEKbB94LSJ1qwbFo8+uRc2UsYZgCqSIMRcyX40GyOkD+Xw==} + electron@40.8.3: + resolution: {integrity: sha512-MH6LK4xM6VVmmtz0nRE0Fe8l2jTKSYTvH1t0ZfbNLw3o6dlBCVTRqQha6uL8ZQVoMy74JyLguGwK7dU7rCKIhw==} engines: {node: '>= 12.20.55'} hasBin: true @@ -696,10 +694,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1042,27 +1036,18 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1282,6 +1267,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -1420,13 +1409,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - tar@7.5.9: - resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} + tar@7.5.11: + resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} temp-file@3.4.0: @@ -1667,7 +1651,7 @@ snapshots: ora: 5.4.1 read-binary-file-arch: 1.0.6 semver: 7.7.4 - tar: 7.5.9 + tar: 7.5.11 yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -1707,7 +1691,7 @@ snapshots: '@isaacs/fs-minipass@4.0.1': dependencies: - minipass: 7.1.2 + minipass: 7.1.3 '@malept/cross-spawn-promise@1.1.1': dependencies: @@ -1749,7 +1733,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tootallnate/once@2.0.0': {} + '@tootallnate/once@3.0.1': {} '@types/cacheable-request@6.0.3': dependencies: @@ -1867,7 +1851,7 @@ snapshots: read-config-file: 6.3.2 sanitize-filename: 1.6.3 semver: 7.7.4 - tar: 6.2.1 + tar: 7.5.11 temp-file: 3.4.0 transitivePeerDependencies: - supports-color @@ -1908,7 +1892,7 @@ snapshots: proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.9 + tar: 7.5.11 temp-file: 3.4.0 tiny-async-pool: 1.3.0 which: 5.0.0 @@ -2018,7 +2002,7 @@ snapshots: builder-util-runtime@9.2.4: dependencies: debug: 4.4.3 - sax: 1.4.4 + sax: 1.6.0 transitivePeerDependencies: - supports-color @@ -2085,7 +2069,7 @@ snapshots: minipass-pipeline: 1.2.4 p-map: 7.0.4 ssri: 12.0.0 - tar: 7.5.9 + tar: 7.5.11 unique-filename: 4.0.0 cacheable-lookup@5.0.4: {} @@ -2115,8 +2099,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chownr@2.0.0: {} - chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -2357,7 +2339,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.7.0: + electron@40.8.3: dependencies: '@electron/get': 2.0.3 '@types/node': 24.10.9 @@ -2536,10 +2518,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - fs-minipass@3.0.3: dependencies: minipass: 7.1.2 @@ -2657,7 +2635,7 @@ snapshots: http-proxy-agent@5.0.0: dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 3.0.1 agent-base: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -2900,20 +2878,13 @@ snapshots: dependencies: yallist: 4.0.0 - minipass@5.0.0: {} - minipass@7.1.2: {} - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 + minipass@7.1.3: {} minizlib@3.1.0: dependencies: - minipass: 7.1.2 - - mkdirp@1.0.4: {} + minipass: 7.1.3 ms@2.1.3: {} @@ -2939,7 +2910,7 @@ snapshots: nopt: 8.1.0 proc-log: 5.0.0 semver: 7.7.4 - tar: 7.5.9 + tar: 7.5.11 tinyglobby: 0.2.15 which: 5.0.0 transitivePeerDependencies: @@ -3149,6 +3120,8 @@ snapshots: sax@1.4.4: {} + sax@1.6.0: {} + semver-compare@1.0.0: optional: true @@ -3318,20 +3291,11 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - tar@7.5.9: + tar@7.5.11: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 diff --git a/devenv.lock b/devenv.lock index 7396145e7..6184f1dc8 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1766087669, + "lastModified": 1773012232, "owner": "cachix", "repo": "devenv", - "rev": "c03eed645ea94da7afbee29da76436b7ce33a5cb", + "rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7", "type": "github" }, "original": { @@ -19,14 +19,14 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1765121682, - "owner": "edolstra", + "lastModified": 1767039857, + "owner": "NixOS", "repo": "flake-compat", - "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", "type": "github" }, "original": { - "owner": "edolstra", + "owner": "NixOS", "repo": "flake-compat", "type": "github" } @@ -40,10 +40,10 @@ ] }, "locked": { - "lastModified": 1765911976, + "lastModified": 1772893680, "owner": "cachix", "repo": "git-hooks.nix", - "rev": "b68b780b69702a090c8bb1b973bab13756cc7a27", + "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", "type": "github" }, "original": { @@ -73,11 +73,14 @@ } }, "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, "locked": { - "lastModified": 1764580874, + "lastModified": 1772749504, "owner": "cachix", "repo": "devenv-nixpkgs", - "rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d", + "rev": "08543693199362c1fddb8f52126030d0d374ba2e", "type": "github" }, "original": { @@ -87,12 +90,29 @@ "type": "github" } }, - "nixpkgs-unstable": { + "nixpkgs-src": { + "flake": false, "locked": { - "lastModified": 1766070988, + "lastModified": 1769922788, + "narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", + "rev": "207d15f1a6603226e1e223dc79ac29c7846da32e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1772773019, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", "type": "github" }, "original": { diff --git a/frontend/package.json b/frontend/package.json index 96363985c..65870b739 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "vikunja-frontend", "description": "The todo app to organize your life.", "private": true, - "version": "0.10.0", + "version": "2.2.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", @@ -81,7 +81,7 @@ "bulma-css-variables": "0.9.33", "change-case": "5.4.4", "dayjs": "1.11.19", - "dompurify": "3.3.1", + "dompurify": "3.3.2", "fast-deep-equal": "3.1.3", "flatpickr": "4.6.13", "flexsearch": "0.8.212", @@ -110,49 +110,49 @@ "@histoire/plugin-vue": "1.0.0-beta.1", "@playwright/test": "1.58.2", "@sentry/vite-plugin": "3.6.1", - "@tailwindcss/vite": "4.2.1", + "@tailwindcss/vite": "4.2.2", "@tsconfig/node24": "24.0.4", "@types/codemirror": "5.60.17", "@types/is-touch-device": "1.0.3", - "@types/node": "24.11.0", + "@types/node": "24.12.0", "@types/sortablejs": "1.15.9", - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@vitejs/plugin-vue": "6.0.4", + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@vitejs/plugin-vue": "6.0.5", "@vue/eslint-config-typescript": "14.7.0", "@vue/test-utils": "2.4.6", "@vue/tsconfig": "0.9.0", "@vueuse/shared": "14.2.1", "autoprefixer": "10.4.27", "browserslist": "4.28.1", - "caniuse-lite": "1.0.30001776", + "caniuse-lite": "1.0.30001781", "csstype": "3.2.3", - "esbuild": "0.27.3", - "eslint": "9.39.3", + "esbuild": "0.27.4", + "eslint": "9.39.4", "eslint-plugin-depend": "1.5.0", "eslint-plugin-vue": "10.8.0", - "happy-dom": "20.8.3", + "happy-dom": "20.8.4", "histoire": "1.0.0-beta.1", "postcss": "8.5.8", "postcss-easing-gradients": "3.0.1", "postcss-preset-env": "11.2.0", - "rollup": "4.59.0", + "rollup": "4.60.0", "rollup-plugin-visualizer": "6.0.11", - "sass-embedded": "1.97.3", - "stylelint": "17.4.0", + "sass-embedded": "1.98.0", + "stylelint": "17.5.0", "stylelint-config-property-sort-order-smacss": "10.0.0", "stylelint-config-recommended-vue": "1.6.1", "stylelint-config-standard-scss": "17.0.0", "stylelint-use-logical": "2.1.3", - "tailwindcss": "4.2.1", + "tailwindcss": "4.2.2", "typescript": "5.9.3", "unplugin-inject-preload": "3.0.0", "vite": "7.3.1", "vite-plugin-pwa": "1.2.0", - "vite-plugin-vue-devtools": "8.0.7", - "vite-svg-loader": "5.1.0", - "vitest": "4.0.18", - "vue-tsc": "3.2.5", + "vite-plugin-vue-devtools": "8.1.0", + "vite-svg-loader": "5.1.1", + "vitest": "4.1.0", + "vue-tsc": "3.2.6", "wait-on": "9.0.4", "workbox-cli": "7.4.0" }, @@ -168,7 +168,8 @@ "minimatch": "^10.2.3", "rollup": "$rollup", "basic-ftp": "5.2.0", - "serialize-javascript": "^7.0.3" + "serialize-javascript": "^7.0.3", + "flatted": "^3.4.1" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a4ef09464..3c0a1a021 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,9 +6,10 @@ settings: overrides: minimatch: ^10.2.3 - rollup: 4.59.0 + rollup: 4.60.0 basic-ftp: 5.2.0 serialize-javascript: ^7.0.3 + flatted: ^3.4.1 importers: @@ -31,7 +32,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.3(jiti@2.4.2))(rollup@4.59.0)(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.4.2))(rollup@4.60.0)(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)) @@ -105,8 +106,8 @@ importers: specifier: 1.11.19 version: 1.11.19 dompurify: - specifier: 3.3.1 - version: 3.3.1 + specifier: 3.3.2 + version: 3.3.2 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -176,10 +177,10 @@ importers: version: 10.3.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.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(typescript@5.9.3) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(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.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -187,8 +188,8 @@ importers: specifier: 3.6.1 version: 3.6.1 '@tailwindcss/vite': - specifier: 4.2.1 - version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + specifier: 4.2.2 + version: 4.2.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) '@tsconfig/node24': specifier: 24.0.4 version: 24.0.4 @@ -199,23 +200,23 @@ importers: specifier: 1.0.3 version: 1.0.3 '@types/node': - specifier: 24.11.0 - version: 24.11.0 + specifier: 24.12.0 + version: 24.12.0 '@types/sortablejs': specifier: 1.15.9 version: 1.15.9 '@typescript-eslint/eslint-plugin': - specifier: 8.56.1 - version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + specifier: 8.57.1 + version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.56.1 - version: 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + specifier: 8.57.1 + version: 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@vitejs/plugin-vue': - specifier: 6.0.4 - version: 6.0.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) + specifier: 6.0.5 + version: 6.0.5(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.7.0 - version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2))))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2))))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.6 version: 2.4.6 @@ -232,29 +233,29 @@ importers: specifier: 4.28.1 version: 4.28.1 caniuse-lite: - specifier: 1.0.30001776 - version: 1.0.30001776 + specifier: 1.0.30001781 + version: 1.0.30001781 csstype: specifier: 3.2.3 version: 3.2.3 esbuild: - specifier: 0.27.3 - version: 0.27.3 + specifier: 0.27.4 + version: 0.27.4 eslint: - specifier: 9.39.3 - version: 9.39.3(jiti@2.4.2) + specifier: 9.39.4 + version: 9.39.4(jiti@2.4.2) eslint-plugin-depend: specifier: 1.5.0 - version: 1.5.0(eslint@9.39.3(jiti@2.4.2)) + version: 1.5.0(eslint@9.39.4(jiti@2.4.2)) eslint-plugin-vue: specifier: 10.8.0 - version: 10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2))) + version: 10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2))) happy-dom: - specifier: 20.8.3 - version: 20.8.3 + specifier: 20.8.4 + version: 20.8.4 histoire: specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(@types/node@24.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) + version: 1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) postcss: specifier: 8.5.8 version: 8.5.8 @@ -265,32 +266,32 @@ importers: specifier: 11.2.0 version: 11.2.0(postcss@8.5.8) rollup: - specifier: 4.59.0 - version: 4.59.0 + specifier: 4.60.0 + version: 4.60.0 rollup-plugin-visualizer: specifier: 6.0.11 - version: 6.0.11(rollup@4.59.0) + version: 6.0.11(rollup@4.60.0) sass-embedded: - specifier: 1.97.3 - version: 1.97.3 + specifier: 1.98.0 + version: 1.98.0 stylelint: - specifier: 17.4.0 - version: 17.4.0(typescript@5.9.3) + specifier: 17.5.0 + version: 17.5.0(typescript@5.9.3) stylelint-config-property-sort-order-smacss: specifier: 10.0.0 - version: 10.0.0(stylelint@17.4.0(typescript@5.9.3)) + version: 10.0.0(stylelint@17.5.0(typescript@5.9.3)) stylelint-config-recommended-vue: specifier: 1.6.1 - version: 1.6.1(postcss-html@1.8.0)(stylelint@17.4.0(typescript@5.9.3)) + version: 1.6.1(postcss-html@1.8.0)(stylelint@17.5.0(typescript@5.9.3)) stylelint-config-standard-scss: specifier: 17.0.0 - version: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)) stylelint-use-logical: specifier: 2.1.3 - version: 2.1.3(stylelint@17.4.0(typescript@5.9.3)) + version: 2.1.3(stylelint@17.5.0(typescript@5.9.3)) tailwindcss: - specifier: 4.2.1 - version: 4.2.1 + specifier: 4.2.2 + version: 4.2.2 typescript: specifier: 5.9.3 version: 5.9.3 @@ -299,22 +300,22 @@ importers: version: 3.0.0 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + version: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) vite-plugin-pwa: specifier: 1.2.0 - version: 1.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(workbox-build@7.4.0)(workbox-window@7.4.0) + version: 1.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(workbox-build@7.4.0)(workbox-window@7.4.0) vite-plugin-vue-devtools: - specifier: 8.0.7 - version: 8.0.7(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) + specifier: 8.1.0 + version: 8.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)) vite-svg-loader: - specifier: 5.1.0 - version: 5.1.0(vue@3.5.27(typescript@5.9.3)) + specifier: 5.1.1 + version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: - specifier: 4.0.18 - version: 4.0.18(@types/node@24.11.0)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@27.4.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + specifier: 4.1.0 + version: 4.1.0(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@27.4.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) vue-tsc: - specifier: 3.2.5 - version: 3.2.5(typescript@5.9.3) + specifier: 3.2.6 + version: 3.2.6(typescript@5.9.3) wait-on: specifier: 9.0.4 version: 9.0.4 @@ -963,12 +964,17 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.26': - resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} - '@csstools/css-syntax-patches-for-csstree@1.0.28': resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} @@ -1260,8 +1266,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -1272,8 +1278,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -1284,8 +1290,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -1296,8 +1302,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -1308,8 +1314,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -1320,8 +1326,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -1332,8 +1338,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -1344,8 +1350,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -1356,8 +1362,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -1368,8 +1374,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -1380,8 +1386,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -1392,8 +1398,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -1404,8 +1410,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -1416,8 +1422,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1428,8 +1434,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1440,8 +1446,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1452,8 +1458,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1464,8 +1470,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1476,8 +1482,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1488,8 +1494,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1500,8 +1506,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1512,8 +1518,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -1524,8 +1530,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1536,8 +1542,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1548,8 +1554,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1560,8 +1566,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1582,8 +1588,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -1594,12 +1600,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.3': - resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -2012,7 +2018,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0 '@types/babel__core': ^7.1.9 - rollup: 4.59.0 + rollup: 4.60.0 peerDependenciesMeta: '@types/babel__core': optional: true @@ -2021,7 +2027,7 @@ packages: resolution: {integrity: sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.59.0 + rollup: 4.60.0 peerDependenciesMeta: rollup: optional: true @@ -2029,13 +2035,13 @@ packages: '@rollup/plugin-replace@2.4.2': resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} peerDependencies: - rollup: 4.59.0 + rollup: 4.60.0 '@rollup/plugin-terser@0.4.4': resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.59.0 + rollup: 4.60.0 peerDependenciesMeta: rollup: optional: true @@ -2044,139 +2050,139 @@ packages: resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} engines: {node: '>= 8.0.0'} peerDependencies: - rollup: 4.59.0 + rollup: 4.60.0 '@rollup/pluginutils@5.1.3': resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.59.0 + rollup: 4.60.0 peerDependenciesMeta: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] @@ -2313,68 +2319,71 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2385,26 +2394,26 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.1': - resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 + vite: ^5.2.0 || ^6 || ^7 || ^8 '@tiptap/core@3.17.0': resolution: {integrity: sha512-jpGwcSdr0WRmLRmQWAYo6DlR2lIoZ7XYq8/slwJvC/4GUbafVzYiyGlJLRxhh/9LYTIz5FUavThFKd4y6OtOQw==} @@ -2594,10 +2603,6 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - '@tsconfig/node24@24.0.4': resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==} @@ -2646,8 +2651,8 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2687,11 +2692,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.57.1': + resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.57.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' @@ -2702,8 +2707,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.57.1': + resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2721,8 +2726,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.57.1': + resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -2735,8 +2740,8 @@ packages: resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + '@typescript-eslint/scope-manager@8.57.1': + resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.49.0': @@ -2751,8 +2756,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.57.1': + resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -2764,8 +2775,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.57.1': + resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2779,8 +2790,12 @@ packages: resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.56.1': - resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.49.0': @@ -2795,8 +2810,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/typescript-estree@8.57.1': + resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -2808,8 +2823,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.57.1': + resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2823,48 +2838,48 @@ packages: resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/visitor-keys@8.57.1': + resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-vue@6.0.4': - resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + '@vitejs/plugin-vue@6.0.5': + resolution: {integrity: sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2909,22 +2924,22 @@ packages: '@vue/devtools-api@7.7.7': resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} - '@vue/devtools-core@8.0.7': - resolution: {integrity: sha512-PmpiPxvg3Of80ODHVvyckxwEW1Z02VIAvARIZS1xegINn3VuNQLm9iHUmKD+o6cLkMNWV8OG8x7zo0kgydZgdg==} + '@vue/devtools-core@8.1.0': + resolution: {integrity: sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ==} peerDependencies: vue: ^3.0.0 '@vue/devtools-kit@7.7.7': resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} - '@vue/devtools-kit@8.0.7': - resolution: {integrity: sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==} + '@vue/devtools-kit@8.1.0': + resolution: {integrity: sha512-/NZlS4WtGIB54DA/z10gzk+n/V7zaqSzYZOVlg2CfdnpIKdB61bd7JDIMxf/zrtX41zod8E2/bbEBoW/d7x70Q==} '@vue/devtools-shared@7.7.7': resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} - '@vue/devtools-shared@8.0.7': - resolution: {integrity: sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==} + '@vue/devtools-shared@8.1.0': + resolution: {integrity: sha512-h8uCb4Qs8UT8VdTT5yjY6tOJ//qH7EpxToixR0xqejR55t5OdISIg7AJ7eBkhBs8iu1qG5gY3QQNN1DF1EelAA==} '@vue/eslint-config-typescript@14.7.0': resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} @@ -2937,8 +2952,8 @@ packages: typescript: optional: true - '@vue/language-core@3.2.5': - resolution: {integrity: sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==} + '@vue/language-core@3.2.6': + resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -3046,6 +3061,10 @@ packages: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -3274,8 +3293,8 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001776: - resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} capture-website@4.2.0: resolution: {integrity: sha512-EmkSn36CXTC8tUsS6aNmvvsdpfVTYYkuRp7U5bV9gcJwcDbqqA5c0Op/iskYPKtDdOkuVp61mjn/LLywX0h7cw==} @@ -3416,6 +3435,15 @@ packages: typescript: optional: true + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3467,6 +3495,10 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} @@ -3625,11 +3657,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} - - domutils@3.1.0: - resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3738,6 +3768,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3755,8 +3788,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -3819,8 +3852,8 @@ packages: resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.3: - resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -3966,8 +3999,8 @@ packages: flatpickr@4.6.13: resolution: {integrity: sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} flexsearch@0.8.212: resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} @@ -4055,6 +4088,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4130,8 +4167,8 @@ packages: resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} engines: {node: '>=18'} - globby@16.1.0: - resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} + globby@16.1.1: + resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} engines: {node: '>=20'} globjoin@0.1.4: @@ -4151,8 +4188,8 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - happy-dom@20.8.3: - resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} + happy-dom@20.8.4: + resolution: {integrity: sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -4266,8 +4303,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -4619,74 +4656,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -4810,11 +4847,14 @@ packages: mdn-data@2.26.0: resolution: {integrity: sha512-ZqI0qjKWHMPcGUfLmlr80NPNVHIOjPMHtIOe1qXYFGS0YBZ1YKAzo9yk8W+gGrLCN0Xdv/RKxqdIsqPakEfmow==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - meow@14.0.0: - resolution: {integrity: sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} engines: {node: '>=20'} meow@7.1.1: @@ -5578,15 +5618,15 @@ packages: hasBin: true peerDependencies: rolldown: 1.x || ^1.0.0-beta - rollup: 4.59.0 + rollup: 4.60.0 peerDependenciesMeta: rolldown: optional: true rollup: optional: true - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5629,120 +5669,124 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass-embedded-all-unknown@1.97.3: - resolution: {integrity: sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==} + sass-embedded-all-unknown@1.98.0: + resolution: {integrity: sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.97.3: - resolution: {integrity: sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==} + sass-embedded-android-arm64@1.98.0: + resolution: {integrity: sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.97.3: - resolution: {integrity: sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==} + sass-embedded-android-arm@1.98.0: + resolution: {integrity: sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.97.3: - resolution: {integrity: sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==} + sass-embedded-android-riscv64@1.98.0: + resolution: {integrity: sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.97.3: - resolution: {integrity: sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==} + sass-embedded-android-x64@1.98.0: + resolution: {integrity: sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.97.3: - resolution: {integrity: sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA==} + sass-embedded-darwin-arm64@1.98.0: + resolution: {integrity: sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.97.3: - resolution: {integrity: sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==} + sass-embedded-darwin-x64@1.98.0: + resolution: {integrity: sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.97.3: - resolution: {integrity: sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==} + sass-embedded-linux-arm64@1.98.0: + resolution: {integrity: sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-arm@1.97.3: - resolution: {integrity: sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==} + sass-embedded-linux-arm@1.98.0: + resolution: {integrity: sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-arm64@1.97.3: - resolution: {integrity: sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==} + sass-embedded-linux-musl-arm64@1.98.0: + resolution: {integrity: sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-musl-arm@1.97.3: - resolution: {integrity: sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==} + sass-embedded-linux-musl-arm@1.98.0: + resolution: {integrity: sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-riscv64@1.97.3: - resolution: {integrity: sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==} + sass-embedded-linux-musl-riscv64@1.98.0: + resolution: {integrity: sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-musl-x64@1.97.3: - resolution: {integrity: sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==} + sass-embedded-linux-musl-x64@1.98.0: + resolution: {integrity: sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-linux-riscv64@1.97.3: - resolution: {integrity: sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==} + sass-embedded-linux-riscv64@1.98.0: + resolution: {integrity: sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-x64@1.97.3: - resolution: {integrity: sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==} + sass-embedded-linux-x64@1.98.0: + resolution: {integrity: sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-unknown-all@1.97.3: - resolution: {integrity: sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==} + sass-embedded-unknown-all@1.98.0: + resolution: {integrity: sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.97.3: - resolution: {integrity: sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==} + sass-embedded-win32-arm64@1.98.0: + resolution: {integrity: sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.97.3: - resolution: {integrity: sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==} + sass-embedded-win32-x64@1.98.0: + resolution: {integrity: sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.97.3: - resolution: {integrity: sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==} + sass-embedded@1.98.0: + resolution: {integrity: sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==} engines: {node: '>=16.0.0'} hasBin: true - sass@1.97.3: - resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} + sass@1.98.0: + resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} engines: {node: '>=14.0.0'} hasBin: true + sax@1.5.0: + resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -5910,8 +5954,8 @@ packages: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} @@ -5928,8 +5972,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.1.1: - resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} string.prototype.matchall@4.0.11: @@ -5965,6 +6009,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} @@ -6069,8 +6117,8 @@ packages: peerDependencies: stylelint: '>= 11 < 18' - stylelint@17.4.0: - resolution: {integrity: sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==} + stylelint@17.5.0: + resolution: {integrity: sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==} engines: {node: '>=20.19.0'} hasBin: true @@ -6101,8 +6149,8 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - svgo@3.3.2: - resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + svgo@3.3.3: + resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} engines: {node: '>=14.0.0'} hasBin: true @@ -6121,8 +6169,8 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -6456,19 +6504,19 @@ packages: '@vite-pwa/assets-generator': optional: true - vite-plugin-vue-devtools@8.0.7: - resolution: {integrity: sha512-BWj/ykGpqVAJVdPyHmSTUm44buz3jPv+6jnvuFdQSRH0kAgP1cEIE4doHiFyqHXOmuB5EQVR/nh2g9YRiRNs9g==} + vite-plugin-vue-devtools@8.1.0: + resolution: {integrity: sha512-4AvNRePfni3+PqOunACmAImC6SJVpUv6f7/g4oakyre9hYdEMrvDYlNmTZQsJPzVLMcGzn1FvSEqJ/n4HQ9cDg==} engines: {node: '>=v14.21.3'} peerDependencies: - vite: ^6.0.0 || ^7.0.0-0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 vite-plugin-vue-inspector@5.3.2: resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==} peerDependencies: vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - vite-svg-loader@5.1.0: - resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==} + vite-svg-loader@5.1.1: + resolution: {integrity: sha512-RPzcXA/EpKJA0585x58DBgs7my2VfeJ+j2j1EoHY4Zh82Y7hV4cR1fElgy2aZi85+QSrcLLoTStQ5uZjD68u+Q==} peerDependencies: vue: '>=3.2.13' @@ -6512,20 +6560,21 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} 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.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -6597,8 +6646,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.2.5: - resolution: {integrity: sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==} + vue-tsc@3.2.6: + resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -7687,10 +7736,12 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.26': {} - '@csstools/css-syntax-patches-for-csstree@1.0.28': {} + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@4.0.0': {} @@ -7993,172 +8044,172 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.27.4': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.27.4': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.27.4': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.27.4': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.27.4': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.27.4': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.27.4': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.27.4': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.27.4': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.27.4': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.27.4': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.27.4': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.27.4': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.27.4': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.27.4': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.27.4': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.27.4': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.27.4': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.27.4': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.27.4': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.27.4': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.27.4': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.27.4': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.27.4': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.27.4': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.3(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.4(jiti@2.4.2))': dependencies: - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.4.2))': dependencies: - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 @@ -8174,7 +8225,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -8188,7 +8239,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.3': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -8273,17 +8324,17 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@histoire/app@1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))': + '@histoire/app@1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) '@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.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))': + '@histoire/controls@1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))': dependencies: '@codemirror/commands': 6.8.1 '@codemirror/lang-json': 6.0.1 @@ -8292,17 +8343,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.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) '@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.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(typescript@5.9.3)': + '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(typescript@5.9.3)': dependencies: capture-website: 4.2.0(typescript@5.9.3) defu: 6.1.4 fs-extra: 11.2.0 - histoire: 1.0.0-beta.1(@types/node@24.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) + histoire: 1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) pathe: 1.1.2 transitivePeerDependencies: - bare-buffer @@ -8311,21 +8362,21 @@ snapshots: - typescript - utf-8-validate - '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(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.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) '@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.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) + histoire: 1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0) launch-editor: 2.10.0 pathe: 1.1.2 vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - vite - '@histoire/shared@1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))': + '@histoire/shared@1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))': dependencies: '@histoire/vendors': 1.0.0-beta.1 '@types/fs-extra': 11.0.4 @@ -8333,7 +8384,7 @@ snapshots: chokidar: 4.0.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) '@histoire/vendors@1.0.0-beta.1': {} @@ -8383,13 +8434,13 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.3(jiti@2.4.2))(rollup@4.59.0)(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.4.2))(rollup@4.60.0)(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.0(eslint@9.39.3(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.4(jiti@2.4.2)) '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3))) '@intlify/shared': 11.2.2 '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.2)(@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.59.0) + '@rollup/pluginutils': 5.1.3(rollup@4.60.0) '@typescript-eslint/scope-manager': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) debug: 4.4.3 @@ -8613,128 +8664,128 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.2': {} - '@rollup/plugin-babel@5.3.1(@babel/core@7.26.0)(rollup@4.59.0)': + '@rollup/plugin-babel@5.3.1(@babel/core@7.26.0)(rollup@4.60.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.25.9 - '@rollup/pluginutils': 3.1.0(rollup@4.59.0) - rollup: 4.59.0 + '@rollup/pluginutils': 3.1.0(rollup@4.60.0) + rollup: 4.60.0 transitivePeerDependencies: - supports-color - '@rollup/plugin-node-resolve@15.2.3(rollup@4.59.0)': + '@rollup/plugin-node-resolve@15.2.3(rollup@4.60.0)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.59.0) + '@rollup/pluginutils': 5.1.3(rollup@4.60.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.8 optionalDependencies: - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/plugin-replace@2.4.2(rollup@4.59.0)': + '@rollup/plugin-replace@2.4.2(rollup@4.60.0)': dependencies: - '@rollup/pluginutils': 3.1.0(rollup@4.59.0) + '@rollup/pluginutils': 3.1.0(rollup@4.60.0) magic-string: 0.25.9 - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/plugin-terser@0.4.4(rollup@4.59.0)': + '@rollup/plugin-terser@0.4.4(rollup@4.60.0)': dependencies: serialize-javascript: 7.0.3 smob: 1.5.0 terser: 5.31.6 optionalDependencies: - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/pluginutils@3.1.0(rollup@4.59.0)': + '@rollup/pluginutils@3.1.0(rollup@4.60.0)': dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 picomatch: 2.3.1 - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/pluginutils@5.1.3(rollup@4.59.0)': + '@rollup/pluginutils@5.1.3(rollup@4.60.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.59.0 + rollup: 4.60.0 - '@rollup/rollup-android-arm-eabi@4.59.0': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.0': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.59.0': + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.59.0': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.59.0': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.59.0': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.59.0': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.59.0': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.59.0': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.59.0': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true '@sentry-internal/browser-utils@10.36.0': @@ -8880,6 +8931,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -8887,73 +8940,73 @@ snapshots: magic-string: 0.25.9 string.prototype.matchall: 4.0.11 - '@tailwindcss/node@4.2.1': + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.20.0 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/oxide-android-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.2.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.2.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.2.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': optional: true - '@tailwindcss/oxide@4.2.1': + '@tailwindcss/oxide@4.2.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))': + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))': dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) '@tiptap/core@3.17.0(@tiptap/pm@3.17.0)': dependencies: @@ -9159,8 +9212,6 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@trysound/sax@0.2.0': {} - '@tsconfig/node24@24.0.4': {} '@types/chai@5.2.2': @@ -9180,7 +9231,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/hast@3.0.4': dependencies: @@ -9192,7 +9243,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/linkify-it@5.0.0': {} @@ -9209,7 +9260,7 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/node@24.11.0': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -9233,22 +9284,22 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 optional: true - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -9256,15 +9307,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.3(jiti@2.4.2) + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + eslint: 9.39.4(jiti@2.4.2) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -9272,34 +9323,34 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9307,17 +9358,17 @@ snapshots: '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9333,10 +9384,10 @@ snapshots: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.57.1': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': dependencies: @@ -9346,29 +9397,33 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -9378,7 +9433,9 @@ snapshots: '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.57.1': {} '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': dependencies: @@ -9410,12 +9467,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.3 @@ -9425,24 +9482,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.4.2)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.3(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.4.2)) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + eslint: 9.39.4(jiti@2.4.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9457,56 +9514,58 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.0 - '@typescript-eslint/visitor-keys@8.56.1': + '@typescript-eslint/visitor-keys@8.57.1': dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.57.1 eslint-visitor-keys: 5.0.0 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) vue: 3.5.27(typescript@5.9.3) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.0': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))': + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.0': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 tinyrainbow: 3.0.3 '@volar/language-core@2.4.28': @@ -9587,10 +9646,10 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@8.0.7(vue@3.5.27(typescript@5.9.3))': + '@vue/devtools-core@8.1.0(vue@3.5.27(typescript@5.9.3))': dependencies: - '@vue/devtools-kit': 8.0.7 - '@vue/devtools-shared': 8.0.7 + '@vue/devtools-kit': 8.1.0 + '@vue/devtools-shared': 8.1.0 vue: 3.5.27(typescript@5.9.3) '@vue/devtools-kit@7.7.7': @@ -9603,9 +9662,9 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 - '@vue/devtools-kit@8.0.7': + '@vue/devtools-kit@8.1.0': dependencies: - '@vue/devtools-shared': 8.0.7 + '@vue/devtools-shared': 8.1.0 birpc: 2.6.1 hookable: 5.5.3 perfect-debounce: 2.0.0 @@ -9614,22 +9673,22 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/devtools-shared@8.0.7': {} + '@vue/devtools-shared@8.1.0': {} - '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2))))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2))))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - eslint: 9.39.3(jiti@2.4.2) - eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2))) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.4.2) + eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2))) fast-glob: 3.3.3 - typescript-eslint: 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@9.39.3(jiti@2.4.2)) + typescript-eslint: 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.4.2)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@vue/language-core@3.2.5': + '@vue/language-core@3.2.6': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -9742,6 +9801,8 @@ snapshots: ansi-regex@6.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -9799,7 +9860,7 @@ snapshots: autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001776 + caniuse-lite: 1.0.30001781 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.8 @@ -9918,7 +9979,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.4 - caniuse-lite: 1.0.30001776 + caniuse-lite: 1.0.30001781 electron-to-chromium: 1.5.266 node-releases: 2.0.27 update-browserslist-db: 1.2.2(browserslist@4.28.1) @@ -9980,7 +10041,7 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001776: {} + caniuse-lite@1.0.30001781: {} capture-website@4.2.0(typescript@5.9.3): dependencies: @@ -10122,6 +10183,15 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -10157,7 +10227,7 @@ snapshots: boolbase: 1.0.0 css-what: 6.1.0 domhandler: 5.0.3 - domutils: 3.1.0 + domutils: 3.2.2 nth-check: 2.1.1 css-tree@2.2.1: @@ -10175,6 +10245,11 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.1.0: {} cssdb@8.8.0: {} @@ -10188,7 +10263,7 @@ snapshots: cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.26 + '@csstools/css-syntax-patches-for-csstree': 1.0.28 css-tree: 3.1.0 lru-cache: 11.2.4 @@ -10310,16 +10385,10 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.3.1: + dompurify@3.3.2: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@3.1.0: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -10451,6 +10520,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10497,34 +10568,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.3: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escalade@3.2.0: {} @@ -10544,25 +10615,25 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-plugin-depend@1.5.0(eslint@9.39.3(jiti@2.4.2)): + eslint-plugin-depend@1.5.0(eslint@9.39.4(jiti@2.4.2)): dependencies: empathic: 2.0.0 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2))): + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.4.2)) - eslint: 9.39.3(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.4.2)) + eslint: 9.39.4(jiti@2.4.2) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.3 - vue-eslint-parser: 10.4.0(eslint@9.39.3(jiti@2.4.2)) + vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.4.2)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -10575,15 +10646,15 @@ snapshots: eslint-visitor-keys@5.0.0: {} - eslint@9.39.3(jiti@2.4.2): + eslint@9.39.4(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.39.3 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -10750,18 +10821,18 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.1 keyv: 4.5.4 flat-cache@6.1.20: dependencies: cacheable: 2.3.2 - flatted: 3.3.3 + flatted: 3.4.1 hookified: 1.15.1 flatpickr@4.6.13: {} - flatted@3.3.3: {} + flatted@3.4.1: {} flexsearch@0.8.212: {} @@ -10837,6 +10908,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10940,7 +11013,7 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.3.0 - globby@16.1.0: + globby@16.1.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 @@ -10964,9 +11037,9 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - happy-dom@20.8.3: + happy-dom@20.8.4: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -11024,12 +11097,12 @@ snapshots: highlight.js@11.11.1: {} - histoire@1.0.0-beta.1(@types/node@24.11.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0): + histoire@1.0.0-beta.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0): dependencies: '@akryum/tinypool': 0.3.1 - '@histoire/app': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/app': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) '@histoire/vendors': 1.0.0-beta.1 '@types/markdown-it': 14.1.2 birpc: 0.2.19 @@ -11054,8 +11127,8 @@ snapshots: sade: 1.8.1 shiki: 3.2.1 sirv: 3.0.2 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) - vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) + vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) transitivePeerDependencies: - '@exodus/crypto' - '@types/node' @@ -11131,7 +11204,7 @@ snapshots: ignore@7.0.5: {} - immutable@5.1.4: {} + immutable@5.1.5: {} import-fresh@3.3.0: dependencies: @@ -11460,54 +11533,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.31.1: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lines-and-columns@1.2.4: {} @@ -11620,9 +11693,11 @@ snapshots: mdn-data@2.26.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} - meow@14.0.0: {} + meow@14.1.0: {} meow@7.1.1: dependencies: @@ -12497,44 +12572,44 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.11(rollup@4.59.0): + rollup-plugin-visualizer@6.0.11(rollup@4.60.0): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.59.0 + rollup: 4.60.0 - rollup@4.59.0: + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -12576,102 +12651,104 @@ snapshots: safer-buffer@2.1.2: {} - sass-embedded-all-unknown@1.97.3: + sass-embedded-all-unknown@1.98.0: dependencies: - sass: 1.97.3 + sass: 1.98.0 optional: true - sass-embedded-android-arm64@1.97.3: + sass-embedded-android-arm64@1.98.0: optional: true - sass-embedded-android-arm@1.97.3: + sass-embedded-android-arm@1.98.0: optional: true - sass-embedded-android-riscv64@1.97.3: + sass-embedded-android-riscv64@1.98.0: optional: true - sass-embedded-android-x64@1.97.3: + sass-embedded-android-x64@1.98.0: optional: true - sass-embedded-darwin-arm64@1.97.3: + sass-embedded-darwin-arm64@1.98.0: optional: true - sass-embedded-darwin-x64@1.97.3: + sass-embedded-darwin-x64@1.98.0: optional: true - sass-embedded-linux-arm64@1.97.3: + sass-embedded-linux-arm64@1.98.0: optional: true - sass-embedded-linux-arm@1.97.3: + sass-embedded-linux-arm@1.98.0: optional: true - sass-embedded-linux-musl-arm64@1.97.3: + sass-embedded-linux-musl-arm64@1.98.0: optional: true - sass-embedded-linux-musl-arm@1.97.3: + sass-embedded-linux-musl-arm@1.98.0: optional: true - sass-embedded-linux-musl-riscv64@1.97.3: + sass-embedded-linux-musl-riscv64@1.98.0: optional: true - sass-embedded-linux-musl-x64@1.97.3: + sass-embedded-linux-musl-x64@1.98.0: optional: true - sass-embedded-linux-riscv64@1.97.3: + sass-embedded-linux-riscv64@1.98.0: optional: true - sass-embedded-linux-x64@1.97.3: + sass-embedded-linux-x64@1.98.0: optional: true - sass-embedded-unknown-all@1.97.3: + sass-embedded-unknown-all@1.98.0: dependencies: - sass: 1.97.3 + sass: 1.98.0 optional: true - sass-embedded-win32-arm64@1.97.3: + sass-embedded-win32-arm64@1.98.0: optional: true - sass-embedded-win32-x64@1.97.3: + sass-embedded-win32-x64@1.98.0: optional: true - sass-embedded@1.97.3: + sass-embedded@1.98.0: dependencies: '@bufbuild/protobuf': 2.5.2 colorjs.io: 0.5.2 - immutable: 5.1.4 + immutable: 5.1.5 rxjs: 7.8.2 supports-color: 8.1.1 sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.97.3 - sass-embedded-android-arm: 1.97.3 - sass-embedded-android-arm64: 1.97.3 - sass-embedded-android-riscv64: 1.97.3 - sass-embedded-android-x64: 1.97.3 - sass-embedded-darwin-arm64: 1.97.3 - sass-embedded-darwin-x64: 1.97.3 - sass-embedded-linux-arm: 1.97.3 - sass-embedded-linux-arm64: 1.97.3 - sass-embedded-linux-musl-arm: 1.97.3 - sass-embedded-linux-musl-arm64: 1.97.3 - sass-embedded-linux-musl-riscv64: 1.97.3 - sass-embedded-linux-musl-x64: 1.97.3 - sass-embedded-linux-riscv64: 1.97.3 - sass-embedded-linux-x64: 1.97.3 - sass-embedded-unknown-all: 1.97.3 - sass-embedded-win32-arm64: 1.97.3 - sass-embedded-win32-x64: 1.97.3 + sass-embedded-all-unknown: 1.98.0 + sass-embedded-android-arm: 1.98.0 + sass-embedded-android-arm64: 1.98.0 + sass-embedded-android-riscv64: 1.98.0 + sass-embedded-android-x64: 1.98.0 + sass-embedded-darwin-arm64: 1.98.0 + sass-embedded-darwin-x64: 1.98.0 + sass-embedded-linux-arm: 1.98.0 + sass-embedded-linux-arm64: 1.98.0 + sass-embedded-linux-musl-arm: 1.98.0 + sass-embedded-linux-musl-arm64: 1.98.0 + sass-embedded-linux-musl-riscv64: 1.98.0 + sass-embedded-linux-musl-x64: 1.98.0 + sass-embedded-linux-riscv64: 1.98.0 + sass-embedded-linux-x64: 1.98.0 + sass-embedded-unknown-all: 1.98.0 + sass-embedded-win32-arm64: 1.98.0 + sass-embedded-win32-x64: 1.98.0 - sass@1.97.3: + sass@1.98.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.4 + immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.1 optional: true + sax@1.5.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -12838,7 +12915,7 @@ snapshots: statuses@1.5.0: {} - std-env@3.10.0: {} + std-env@4.0.0: {} streamx@2.22.0: dependencies: @@ -12865,10 +12942,10 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.0 - string-width@8.1.1: + string-width@8.2.0: dependencies: - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 string.prototype.matchall@4.0.11: dependencies: @@ -12927,6 +13004,10 @@ snapshots: dependencies: ansi-regex: 6.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom-string@1.0.0: {} strip-comments@2.0.1: {} @@ -12947,62 +13028,62 @@ snapshots: style-mod@4.1.2: {} - stylelint-config-html@1.1.0(postcss-html@1.8.0)(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-html@1.1.0(postcss-html@1.8.0)(stylelint@17.5.0(typescript@5.9.3)): dependencies: postcss-html: 1.8.0 - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-property-sort-order-smacss@10.0.0(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-property-sort-order-smacss@10.0.0(stylelint@17.5.0(typescript@5.9.3)): dependencies: css-property-sort-order-smacss: 2.2.0 - stylelint: 17.4.0(typescript@5.9.3) - stylelint-order: 6.0.4(stylelint@17.4.0(typescript@5.9.3)) + stylelint: 17.5.0(typescript@5.9.3) + stylelint-order: 6.0.4(stylelint@17.5.0(typescript@5.9.3)) - stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)): dependencies: postcss-scss: 4.0.9(postcss@8.5.8) - stylelint: 17.4.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@5.9.3)) - stylelint-scss: 7.0.0(stylelint@17.4.0(typescript@5.9.3)) + stylelint: 17.5.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.5.0(typescript@5.9.3)) + stylelint-scss: 7.0.0(stylelint@17.5.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.8 - stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.0)(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.0)(stylelint@17.5.0(typescript@5.9.3)): dependencies: postcss-html: 1.8.0 semver: 7.7.1 - stylelint: 17.4.0(typescript@5.9.3) - stylelint-config-html: 1.1.0(postcss-html@1.8.0)(stylelint@17.4.0(typescript@5.9.3)) - stylelint-config-recommended: 17.0.0(stylelint@17.4.0(typescript@5.9.3)) + stylelint: 17.5.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.0)(stylelint@17.5.0(typescript@5.9.3)) + stylelint-config-recommended: 17.0.0(stylelint@17.5.0(typescript@5.9.3)) - stylelint-config-recommended@17.0.0(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-recommended@17.0.0(stylelint@17.5.0(typescript@5.9.3)): dependencies: - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-recommended@18.0.0(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-recommended@18.0.0(stylelint@17.5.0(typescript@5.9.3)): dependencies: - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)): dependencies: - stylelint: 17.4.0(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)) - stylelint-config-standard: 40.0.0(stylelint@17.4.0(typescript@5.9.3)) + stylelint: 17.5.0(typescript@5.9.3) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)) + stylelint-config-standard: 40.0.0(stylelint@17.5.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.8 - stylelint-config-standard@40.0.0(stylelint@17.4.0(typescript@5.9.3)): + stylelint-config-standard@40.0.0(stylelint@17.5.0(typescript@5.9.3)): dependencies: - stylelint: 17.4.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@5.9.3)) + stylelint: 17.5.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.5.0(typescript@5.9.3)) - stylelint-order@6.0.4(stylelint@17.4.0(typescript@5.9.3)): + stylelint-order@6.0.4(stylelint@17.5.0(typescript@5.9.3)): dependencies: postcss: 8.5.8 postcss-sorting: 8.0.2(postcss@8.5.8) - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint-scss@7.0.0(stylelint@17.4.0(typescript@5.9.3)): + stylelint-scss@7.0.0(stylelint@17.5.0(typescript@5.9.3)): dependencies: css-tree: 3.1.0 is-plain-object: 5.0.0 @@ -13012,31 +13093,31 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint-use-logical@2.1.3(stylelint@17.4.0(typescript@5.9.3)): + stylelint-use-logical@2.1.3(stylelint@17.5.0(typescript@5.9.3)): dependencies: - stylelint: 17.4.0(typescript@5.9.3) + stylelint: 17.5.0(typescript@5.9.3) - stylelint@17.4.0(typescript@5.9.3): + stylelint@17.5.0(typescript@5.9.3): dependencies: '@csstools/css-calc': 3.1.1(@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-syntax-patches-for-csstree': 1.0.28 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@5.9.3) css-functions-list: 3.3.3 - css-tree: 3.1.0 + css-tree: 3.2.1 debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.2 global-modules: 2.0.0 - globby: 16.1.0 + globby: 16.1.1 globjoin: 0.1.4 html-tags: 5.1.0 ignore: 7.0.5 @@ -13044,7 +13125,7 @@ snapshots: imurmurhash: 0.1.4 is-plain-object: 5.0.0 mathml-tag-names: 4.0.0 - meow: 14.0.0 + meow: 14.1.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 @@ -13052,7 +13133,7 @@ snapshots: postcss-safe-parser: 7.0.1(postcss@8.5.8) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - string-width: 8.1.1 + string-width: 8.2.0 supports-hyperlinks: 4.4.0 svg-tags: 1.0.0 table: 6.9.0 @@ -13084,15 +13165,15 @@ snapshots: svg-tags@1.0.0: {} - svgo@3.3.2: + svgo@3.3.3: dependencies: - '@trysound/sax': 0.2.0 commander: 7.2.0 css-select: 5.1.0 css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 picocolors: 1.1.1 + sax: 1.5.0 symbol-tree@3.2.4: {} @@ -13110,7 +13191,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tailwindcss@4.2.1: {} + tailwindcss@4.2.2: {} tapable@2.3.0: {} @@ -13269,13 +13350,13 @@ snapshots: typed-query-selector@2.12.0: {} - typescript-eslint@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.4.2))(typescript@5.9.3) - eslint: 9.39.3(jiti@2.4.2) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.4.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13424,23 +13505,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)): + vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)): dependencies: birpc: 2.6.1 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) - vite-hot-client: 2.1.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) + vite-hot-client: 2.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) - vite-hot-client@2.1.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)): + vite-hot-client@2.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)): dependencies: - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) - vite-node@3.2.4(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0): + vite-node@3.2.4(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) transitivePeerDependencies: - '@types/node' - jiti @@ -13455,7 +13536,7 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)): + vite-plugin-inspect@11.3.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)): dependencies: ansis: 4.1.0 debug: 4.4.3 @@ -13465,37 +13546,37 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.0 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) - vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) + vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) transitivePeerDependencies: - supports-color - vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(workbox-build@7.4.0)(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(workbox-build@7.4.0)(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) workbox-build: 7.4.0 workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.0.7(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)): + vite-plugin-vue-devtools@8.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.27(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 8.0.7(vue@3.5.27(typescript@5.9.3)) - '@vue/devtools-kit': 8.0.7 - '@vue/devtools-shared': 8.0.7 + '@vue/devtools-core': 8.1.0(vue@3.5.27(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.0 + '@vue/devtools-shared': 8.1.0 sirv: 3.0.2 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) - vite-plugin-inspect: 11.3.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - vite-plugin-vue-inspector: 5.3.2(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) + vite-plugin-inspect: 11.3.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + vite-plugin-vue-inspector: 5.3.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@5.3.2(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)): + vite-plugin-vue-inspector@5.3.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)): dependencies: '@babel/core': 7.26.0 '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.0) @@ -13506,71 +13587,64 @@ snapshots: '@vue/compiler-dom': 3.5.27 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) transitivePeerDependencies: - supports-color - vite-svg-loader@5.1.0(vue@3.5.27(typescript@5.9.3)): + vite-svg-loader@5.1.1(vue@3.5.27(typescript@5.9.3)): dependencies: - svgo: 3.3.2 + debug: 4.4.3 + svgo: 3.3.3 vue: 3.5.27(typescript@5.9.3) + transitivePeerDependencies: + - supports-color - vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0): + vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0): dependencies: - esbuild: 0.27.3 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.8 - rollup: 4.59.0 + rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.4.2 - lightningcss: 1.31.1 - sass: 1.97.3 - sass-embedded: 1.97.3 + lightningcss: 1.32.0 + sass: 1.98.0 + sass-embedded: 1.98.0 terser: 5.31.6 yaml: 2.5.0 - vitest@4.0.18(@types/node@24.11.0)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@27.4.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0): + vitest@4.1.0(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@27.4.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.4.2)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)(yaml@2.5.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.4.2)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.31.6)(yaml@2.5.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.11.0 - happy-dom: 20.8.3 + '@types/node': 24.12.0 + happy-dom: 20.8.4 jsdom: 27.4.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vscode-uri@3.0.8: {} @@ -13587,10 +13661,10 @@ snapshots: dependencies: vue: 3.5.27(typescript@5.9.3) - vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.4.2)): + vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.4.2)): dependencies: debug: 4.4.3 - eslint: 9.39.3(jiti@2.4.2) + eslint: 9.39.4(jiti@2.4.2) eslint-scope: 8.4.0 eslint-visitor-keys: 5.0.0 espree: 10.4.0 @@ -13620,10 +13694,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.2.5(typescript@5.9.3): + vue-tsc@3.2.6(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.2.5 + '@vue/language-core': 3.2.6 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): @@ -13746,10 +13820,10 @@ snapshots: '@babel/core': 7.26.0 '@babel/preset-env': 7.26.0(@babel/core@7.26.0) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 5.3.1(@babel/core@7.26.0)(rollup@4.59.0) - '@rollup/plugin-node-resolve': 15.2.3(rollup@4.59.0) - '@rollup/plugin-replace': 2.4.2(rollup@4.59.0) - '@rollup/plugin-terser': 0.4.4(rollup@4.59.0) + '@rollup/plugin-babel': 5.3.1(@babel/core@7.26.0)(rollup@4.60.0) + '@rollup/plugin-node-resolve': 15.2.3(rollup@4.60.0) + '@rollup/plugin-replace': 2.4.2(rollup@4.60.0) + '@rollup/plugin-terser': 0.4.4(rollup@4.60.0) '@surma/rollup-plugin-off-main-thread': 2.2.3 ajv: 8.18.0 common-tags: 1.8.2 @@ -13758,7 +13832,7 @@ snapshots: glob: 11.1.0 lodash: 4.17.23 pretty-bytes: 5.6.0 - rollup: 4.59.0 + rollup: 4.60.0 source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 diff --git a/frontend/src/components/home/ProjectsNavigationItem.vue b/frontend/src/components/home/ProjectsNavigationItem.vue index 9898e7d2b..4ab91f86f 100644 --- a/frontend/src/components/home/ProjectsNavigationItem.vue +++ b/frontend/src/components/home/ProjectsNavigationItem.vue @@ -64,7 +64,6 @@ v-if="project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ" class="menu-list-dropdown" :project="project" - :simple="true" > - - diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 0a63e0e56..d99d3f5ec 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -360,6 +360,7 @@ :edit-enabled="canWrite" :task="task" @taskChanged="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId" + @update:attachments="onAttachmentsUpdated" /> @@ -627,7 +628,6 @@ + + diff --git a/go.mod b/go.mod index d4086a04a..b4bb59ec7 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,10 @@ module code.vikunja.io/api +go 1.25.7 + require ( + code.dny.dev/ssrf v0.2.0 dario.cat/mergo v1.0.2 github.com/ThreeDotsLabs/watermill v1.5.1 github.com/adlio/trello v1.12.0 @@ -26,15 +29,14 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 + github.com/aws/smithy-go v1.24.1 github.com/bbrks/go-blurhash v1.1.1 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/coreos/go-oidc/v3 v3.17.0 - github.com/cweill/gotests v1.9.0 github.com/d4l3k/messagediff v1.2.1 github.com/disintegration/imaging v1.6.2 github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b github.com/fatih/color v1.18.0 - github.com/fclairamb/afero-s3 v0.4.0 github.com/gabriel-vasile/mimetype v1.4.12 github.com/ganigeorgiev/fexpr v0.5.0 github.com/getsentry/sentry-go v0.41.0 @@ -64,7 +66,6 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/samedi/caldav-go v3.0.0+incompatible github.com/schollz/progressbar/v3 v3.19.0 - github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -78,12 +79,11 @@ require ( golang.org/x/net v0.49.0 golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 - golang.org/x/term v0.39.0 + golang.org/x/sys v0.41.0 + golang.org/x/term v0.40.0 golang.org/x/text v0.33.0 gopkg.in/d4l3k/messagediff.v1 v1.2.1 mvdan.cc/xurls/v2 v2.6.0 - src.techknowlogick.com/xgo v1.8.1-0.20241105013731-313dedef864f src.techknowlogick.com/xormigrate v1.7.1 xorm.io/builder v0.3.13 xorm.io/xorm v1.3.11 @@ -91,11 +91,12 @@ require ( require ( filippo.io/edwards25519 v1.1.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect @@ -108,7 +109,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect - github.com/aws/smithy-go v1.24.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -118,13 +118,21 @@ require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.3 // indirect github.com/go-openapi/spec v0.20.4 // indirect @@ -144,6 +152,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.53.0 // indirect + github.com/moby/moby/client v0.2.2 // indirect + github.com/moby/term v0.5.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect @@ -151,6 +163,8 @@ require ( github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/onsi/ginkgo v1.16.4 // indirect github.com/onsi/gomega v1.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -162,6 +176,7 @@ require ( github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect @@ -169,8 +184,14 @@ require ( github.com/tj/assert v0.0.3 // indirect github.com/urfave/cli/v2 v2.3.0 // indirect github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.40.0 // indirect @@ -178,12 +199,13 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.3.0 // indirect + src.techknowlogick.com/xgo v1.9.0 // indirect +) + +tool ( + github.com/magefile/mage + github.com/swaggo/swag/cmd/swag + src.techknowlogick.com/xgo ) replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6 and https://github.com/samedi/caldav-go/pull/7 - -go 1.25.0 - -toolchain go1.25.6 - -replace github.com/fclairamb/afero-s3 => github.com/maggch97/afero-s3 v0.0.0-20260227031452-4db589f0d189 diff --git a/go.sum b/go.sum index a8ddafe99..9bc63607b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +code.dny.dev/ssrf v0.2.0 h1:wCBP990rQQ1CYfRpW+YK1+8xhwUjv189AQ3WMo1jQaI= +code.dny.dev/ssrf v0.2.0/go.mod h1:B+91l25OnyaLIeCx0WRJN5qfJ/4/ZTZxRXgm0lj/2w8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= @@ -8,12 +10,16 @@ gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4Lxi github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= @@ -36,8 +42,6 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iK github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.4 h1:s8fbFscel8NLpnz+ggR7ncW+lqhXIkmyHbgbPeT8yyM= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.4/go.mod h1:BazuWe/q/mMJ/NrSJBTbNBJiLq6u8reodbEZ4giRms4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= @@ -99,6 +103,10 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -108,8 +116,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cweill/gotests v1.9.0 h1:2B0mA22tbAZemMvOzbRzxehXecRrc6Y2j4GDsmoz23U= -github.com/cweill/gotests v1.9.0/go.mod h1:ec4OTmXWVUEIznSTBJcO5s9df8C+4NGiEaUuVJW1pL0= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U= github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -122,7 +130,13 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -130,6 +144,8 @@ github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b h1:+ github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -154,6 +170,11 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -338,8 +359,6 @@ github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJV github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/maggch97/afero-s3 v0.0.0-20260227031452-4db589f0d189 h1:HUcTa93zAphy5hA8akgJtMdTXHodWLBFblyutyvlhek= -github.com/maggch97/afero-s3 v0.0.0-20260227031452-4db589f0d189/go.mod h1:8Shhk3YMlD41DAC5bWRyjQFSsO4RxUKOj99Oxc8/HuU= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -367,6 +386,14 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= +github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= +github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= @@ -399,6 +426,10 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -430,8 +461,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM= -github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -505,6 +536,20 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -537,6 +582,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4= +golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= @@ -592,20 +639,21 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -674,6 +722,8 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= @@ -719,10 +769,12 @@ modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= -src.techknowlogick.com/xgo v1.8.1-0.20241105013731-313dedef864f h1:Dy7qQ31o3z4EV+0ISDH1IldkPxzubOAvV7DW+9HnbNg= -src.techknowlogick.com/xgo v1.8.1-0.20241105013731-313dedef864f/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU= +src.techknowlogick.com/xgo v1.9.0 h1:IlAoK9uRnvniCjX/Mruydonm3XtAe/dgjSV2kBww370= +src.techknowlogick.com/xgo v1.9.0/go.mod h1:gMKej7rx5ksiGKwmKpVA4keweoAoFQyRC0rAZKO0vlw= src.techknowlogick.com/xormigrate v1.7.1 h1:RKGLLUAqJ+zO8iZ7eOc7oLH7f0cs2gfXSZSvBRBHnlY= src.techknowlogick.com/xormigrate v1.7.1/go.mod h1:YGNBdj8prENlySwIKmfoEXp7ILGjAltyKFXD0qLgD7U= xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= diff --git a/magefile.go b/magefile.go index e7782c89c..9f4c32107 100644 --- a/magefile.go +++ b/magefile.go @@ -24,9 +24,9 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "io" - "io/fs" "math" "net" "net/http" @@ -60,7 +60,7 @@ var ( PkgVersion = "unstable" // Aliases are mage aliases of targets - Aliases = map[string]interface{}{ + Aliases = map[string]any{ "build": Build.Build, "check:got-swag": Check.GotSwag, "release": Release.Release, @@ -73,6 +73,7 @@ var ( "dev:prepare-worktree": Dev.PrepareWorktree, "dev:tag-release": Dev.TagRelease, "test:e2e": Test.E2E, + "test:e2e-api": Test.E2EApi, "plugins:build": Plugins.Build, "lint": Check.Golangci, "lint:fix": Check.GolangciFix, @@ -85,21 +86,22 @@ func goDetectVerboseFlag() string { return fmt.Sprintf("-v=%t", mg.Verbose()) } -func runCmdWithOutput(name string, arg ...string) (output []byte, err error) { - cmd := exec.Command(name, arg...) +func runGitCommandWithOutput(ctx context.Context, arg ...string) (output []byte, err error) { + cmd := exec.CommandContext(ctx, "git", arg...) output, err = cmd.Output() if err != nil { - if ee, is := err.(*exec.ExitError); is { - return nil, fmt.Errorf("error running command: %s, %s", string(ee.Stderr), err) + var ee *exec.ExitError + if errors.As(err, &ee) { + return nil, fmt.Errorf("error running command: %s, %w", string(ee.Stderr), err) } - return nil, fmt.Errorf("error running command: %s", err) + return nil, fmt.Errorf("error running command: %w", err) } return output, nil } -func getRawVersionString() (version string, err error) { - version, err = getRawVersionNumber() +func getRawVersionString(ctx context.Context) (version string, err error) { + version, err = getRawVersionNumber(ctx) if err != nil { return } @@ -115,7 +117,7 @@ func getRawVersionString() (version string, err error) { return } -func getRawVersionNumber() (version string, err error) { +func getRawVersionNumber(ctx context.Context) (version string, err error) { versionEnv := os.Getenv("RELEASE_VERSION") if versionEnv != "" { return versionEnv, nil @@ -129,19 +131,19 @@ func getRawVersionNumber() (version string, err error) { return strings.Replace(os.Getenv("DRONE_BRANCH"), "release/v", "", 1), nil } - versionBytes, err := runCmdWithOutput("git", "describe", "--tags", "--always", "--abbrev=10") + versionBytes, err := runGitCommandWithOutput(ctx, "describe", "--tags", "--always", "--abbrev=10") return string(versionBytes), err } -func setVersion() error { - versionNumber, err := getRawVersionNumber() +func setVersion(ctx context.Context) error { + versionNumber, err := getRawVersionNumber(ctx) if err != nil { return err } VersionNumber = strings.Trim(versionNumber, "\n") VersionNumber = strings.Replace(VersionNumber, "-g", "-", 1) - version, err := getRawVersionString() + version, err := getRawVersionString(ctx) if err != nil { return fmt.Errorf("error getting version: %w", err) } @@ -175,13 +177,13 @@ func init() { } // Some variables have external dependencies (like git) which may not always be available. -func initVars() error { +func initVars(ctx context.Context) error { // Always include osusergo to use pure Go os/user implementation instead of CGO. // This prevents SIGFPE crashes when running under systemd without HOME set, // caused by glibc's getpwuid_r() failing in certain environments. // See: https://github.com/go-vikunja/vikunja/issues/2170 Tags = "osusergo " + strings.ReplaceAll(os.Getenv("TAGS"), ",", " ") - if err := setVersion(); err != nil { + if err := setVersion(ctx); err != nil { return err } setBinLocation() @@ -190,8 +192,8 @@ func initVars() error { return nil } -func runAndStreamOutput(cmd string, args ...string) error { - c := exec.Command(cmd, args...) +func runAndStreamOutput(ctx context.Context, cmd string, args ...string) error { + c := exec.CommandContext(ctx, cmd, args...) c.Env = os.Environ() c.Stdout = os.Stdout @@ -203,15 +205,15 @@ func runAndStreamOutput(cmd string, args ...string) error { // Will check if the tool exists and if not install it from the provided import path // If any errors occur, it will exit with a status code of 1. -func checkAndInstallGoTool(tool, importPath string) { - if err := exec.Command(tool).Run(); err != nil && strings.Contains(err.Error(), "executable file not found") { +func checkAndInstallGoTool(ctx context.Context, tool, importPath string) error { + if err := exec.CommandContext(ctx, tool).Run(); err != nil && strings.Contains(err.Error(), "executable file not found") { fmt.Printf("%s not installed, installing %s...\n", tool, importPath) - if err := exec.Command("go", "install", goDetectVerboseFlag(), importPath).Run(); err != nil { - fmt.Printf("Error installing %s\n", tool) - os.Exit(1) + if err := exec.CommandContext(ctx, "go", "install", goDetectVerboseFlag(), importPath).Run(); err != nil { //nolint:gosec // Every caller to checkAndInstallGoTool is hard-coded at time of writing, so no injection possible. + return fmt.Errorf("error installing %s: %w", tool, err) } fmt.Println("Installed.") } + return nil } // Calculates a hash of a file @@ -268,19 +270,19 @@ func copyFile(src, dst string) error { func moveFile(src, dst string) error { inputFile, err := os.Open(src) if err != nil { - return fmt.Errorf("couldn't open source file: %s", err) + return fmt.Errorf("couldn't open source file: %w", err) } defer inputFile.Close() outputFile, err := os.Create(dst) if err != nil { - return fmt.Errorf("couldn't open dest file: %s", err) + return fmt.Errorf("couldn't open dest file: %w", err) } defer outputFile.Close() _, err = io.Copy(outputFile, inputFile) if err != nil { - return fmt.Errorf("writing to output file failed: %s", err) + return fmt.Errorf("writing to output file failed: %w", err) } // Make sure to copy copy the permissions of the original file as well @@ -296,7 +298,7 @@ func moveFile(src, dst string) error { // The copy was successful, so now delete the original file err = os.Remove(src) if err != nil { - return fmt.Errorf("failed removing original file: %s", err) + return fmt.Errorf("failed removing original file: %w", err) } return nil } @@ -315,22 +317,22 @@ func appendToFile(filename, content string) error { const InfoColor = "\033[1;32m%s\033[0m" -func printSuccess(text string, args ...interface{}) { +func printSuccess(text string, args ...any) { text = fmt.Sprintf(text, args...) fmt.Printf(InfoColor+"\n", text) } // getE2EPort returns the port from the given env var, or a random available port. -func getE2EPort(envVar string) (int, error) { +func getE2EPort(ctx context.Context, envVar string) (int, error) { if v := os.Getenv(envVar); v != "" { return strconv.Atoi(v) } - return getRandomPort() + return getRandomPort(ctx) } // getRandomPort finds a random available TCP port. -func getRandomPort() (int, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") +func getRandomPort(ctx context.Context) (int, error) { + l, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") if err != nil { return 0, err } @@ -345,22 +347,29 @@ func setProcessGroup(cmd *exec.Cmd) { } // killProcessGroup sends a signal to the entire process group of the given command. -func killProcessGroup(cmd *exec.Cmd) { - if cmd.Process != nil { - pgid, err := syscall.Getpgid(cmd.Process.Pid) - if err == nil { - syscall.Kill(-pgid, syscall.SIGTERM) - } - cmd.Wait() +func killProcessGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil } + if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil { // use best-effort to kill full process group + err = syscall.Kill(-pgid, syscall.SIGTERM) + if err != nil { + return err + } + } + return cmd.Wait() } // waitForHTTP polls a URL until it returns a 200 status or the timeout expires. -func waitForHTTP(url string, timeout time.Duration) error { +func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error { deadline := time.Now().Add(timeout) client := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { - resp, err := client.Get(url) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -372,59 +381,81 @@ func waitForHTTP(url string, timeout time.Duration) error { return fmt.Errorf("timed out waiting for %s after %s", url, timeout) } -// Fmt formats the code using go fmt -func Fmt() error { - mg.Deps(initVars) - var goFiles []string - err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error { +func ensureFrontendDistExists() error { + distPath := filepath.Join("frontend", "dist") + if _, err := os.Stat(distPath); os.IsNotExist(err) { + if err := os.MkdirAll(distPath, 0o755); err != nil { + return fmt.Errorf("error creating %s: %w", distPath, err) + } + } + + indexFile := filepath.Join(distPath, "index.html") + if _, err := os.Stat(indexFile); os.IsNotExist(err) { + f, err := os.Create(indexFile) if err != nil { - return err + return fmt.Errorf("error creating %s: %w", indexFile, err) } - if !info.IsDir() && filepath.Ext(path) == ".go" { - goFiles = append(goFiles, path) - } - return nil - }) + f.Close() + } + return nil +} + +// Fmt formats the code using go fmt +func Fmt(ctx context.Context) error { + mg.Deps(initVars, ensureFrontendDistExists) + out, err := exec.CommandContext(ctx, "git", "ls-files", "--cached", "--others", "--exclude-standard", "*.go").Output() if err != nil { - return err + return fmt.Errorf("failed to list go files from git: %w", err) + } + goFiles := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(goFiles) == 0 || (len(goFiles) == 1 && goFiles[0] == "") { + return nil } args := append([]string{"-s", "-w"}, goFiles...) - return runAndStreamOutput("gofmt", args...) + return runAndStreamOutput(ctx, "gofmt", args...) } type Test mg.Namespace // Feature runs the feature tests -func (Test) Feature() error { +func (Test) Feature(ctx context.Context) error { mg.Deps(initVars) // We run everything sequentially and not in parallel to prevent issues with real test databases - return runAndStreamOutput("go", "test", goDetectVerboseFlag(), "-p", "1", "-coverprofile", "cover.out", "-timeout", "45m", "-short", "./...") + return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-coverprofile", "cover.out", "-timeout", "45m", "-short", "./...") } // Coverage runs the tests and builds the coverage html file from coverage output -func (Test) Coverage() error { +func (Test) Coverage(ctx context.Context) error { mg.Deps(initVars) mg.Deps(Test.Feature) - return runAndStreamOutput("go", "tool", "cover", "-html=cover.out", "-o", "cover.html") + return runAndStreamOutput(ctx, "go", "tool", "cover", "-html=cover.out", "-o", "cover.html") } // Web runs the web tests -func (Test) Web() error { +func (Test) Web(ctx context.Context) error { mg.Deps(initVars) // We run everything sequentially and not in parallel to prevent issues with real test databases args := []string{"test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/webtests"} - return runAndStreamOutput("go", args...) + return runAndStreamOutput(ctx, "go", args...) } -func (Test) Filter(filter string) error { +func (Test) Filter(ctx context.Context, filter string) error { mg.Deps(initVars) // We run everything sequentially and not in parallel to prevent issues with real test databases - return runAndStreamOutput("go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "-run", filter, "-short", "./...") + return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "-run", filter, "-short", "./...") } func (Test) All() { mg.Deps(initVars) - mg.Deps(Test.Feature, Test.Web) + mg.Deps(Test.Feature, Test.Web, Test.E2EApi) +} + +// E2EApi runs the end-to-end API tests in pkg/e2etests. +// These tests use the real event system (not events.Fake()) to verify +// the full async pipeline: web handler → DB → event dispatch → watermill → listener. +func (Test) E2EApi(ctx context.Context) error { + mg.Deps(initVars) + return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/e2etests") } // E2E builds the API, starts it with an in-memory database and the frontend dev server, @@ -444,15 +475,15 @@ func (Test) All() { // - VIKUNJA_E2E_FRONTEND_PORT: Frontend port (default: random) // - VIKUNJA_E2E_TESTING_TOKEN: Testing token for seed endpoints (default: random) // - VIKUNJA_E2E_SKIP_BUILD: Set to "true" to skip rebuilding the API binary (default: false) -func (Test) E2E(args string) error { +func (Test) E2E(ctx context.Context, args string) error { mg.Deps(initVars) // Determine ports - apiPort, err := getE2EPort("VIKUNJA_E2E_API_PORT") + apiPort, err := getE2EPort(ctx, "VIKUNJA_E2E_API_PORT") if err != nil { return fmt.Errorf("could not get API port: %w", err) } - frontendPort, err := getE2EPort("VIKUNJA_E2E_FRONTEND_PORT") + frontendPort, err := getE2EPort(ctx, "VIKUNJA_E2E_FRONTEND_PORT") if err != nil { return fmt.Errorf("could not get frontend port: %w", err) } @@ -471,7 +502,7 @@ func (Test) E2E(args string) error { // Build the API binary (unless skipped) if os.Getenv("VIKUNJA_E2E_SKIP_BUILD") != "true" { fmt.Println("\n--- Building API binary ---") - if err := (Build{}).Build(); err != nil { + if err := (Build{}).Build(ctx); err != nil { return fmt.Errorf("failed to build API: %w", err) } } @@ -493,7 +524,7 @@ func (Test) E2E(args string) error { // Start the API server — all config via env vars, no config file // Uses in-memory SQLite (no DB file on disk) fmt.Println("\n--- Starting API server ---") - apiCmd := exec.Command("./vikunja", "web") + apiCmd := exec.CommandContext(ctx, "./vikunja", "web") apiCmd.Env = append(os.Environ(), fmt.Sprintf("VIKUNJA_SERVICE_INTERFACE=:%d", apiPort), fmt.Sprintf("VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:%d/", apiPort), @@ -516,20 +547,22 @@ func (Test) E2E(args string) error { } defer func() { fmt.Println("\n--- Stopping API server ---") - killProcessGroup(apiCmd) + if err := killProcessGroup(apiCmd); err != nil { + fmt.Println("Failed to stop API server:", err) + } }() // Wait for API to be ready apiBase := fmt.Sprintf("http://127.0.0.1:%d/api/v1", apiPort) fmt.Printf("Waiting for API at %s ...\n", apiBase) - if err := waitForHTTP(apiBase+"/info", 30*time.Second); err != nil { + if err := waitForHTTP(ctx, apiBase+"/info", 30*time.Second); err != nil { return fmt.Errorf("API failed to start: %w", err) } printSuccess("API is ready!") // Build the frontend fmt.Println("\n--- Building frontend ---") - buildFrontendCmd := exec.Command("pnpm", "build:dev") + buildFrontendCmd := exec.CommandContext(ctx, "pnpm", "build:dev") buildFrontendCmd.Dir = "frontend" buildFrontendCmd.Stdout = os.Stdout buildFrontendCmd.Stderr = os.Stderr @@ -540,7 +573,7 @@ func (Test) E2E(args string) error { // Serve the built frontend with vite preview (static, no file watchers) fmt.Println("\n--- Starting frontend preview server ---") - frontendCmd := exec.Command("pnpm", "preview:dev", "--port", strconv.Itoa(frontendPort)) + frontendCmd := exec.CommandContext(ctx, "pnpm", "preview:dev", "--port", strconv.Itoa(frontendPort)) //nolint:gosec // This mage task runs end to end tests with environment-based configuration, it must use the port environment variable to suit its current environment. frontendCmd.Dir = "frontend" frontendCmd.Stdout = os.Stdout frontendCmd.Stderr = os.Stderr @@ -550,13 +583,15 @@ func (Test) E2E(args string) error { } defer func() { fmt.Println("\n--- Stopping frontend preview server ---") - killProcessGroup(frontendCmd) + if err := killProcessGroup(frontendCmd); err != nil { + fmt.Println("Failed to stop API server:", err) + } }() // Wait for frontend to be ready frontendBase := fmt.Sprintf("http://127.0.0.1:%d", frontendPort) fmt.Printf("Waiting for frontend at %s ...\n", frontendBase) - if err := waitForHTTP(frontendBase, 60*time.Second); err != nil { + if err := waitForHTTP(ctx, frontendBase, 60*time.Second); err != nil { return fmt.Errorf("frontend failed to start: %w", err) } printSuccess("Frontend is ready!") @@ -567,7 +602,7 @@ func (Test) E2E(args string) error { if strings.TrimSpace(args) != "" { playwrightArgs = append(playwrightArgs, strings.Fields(args)...) } - playwrightCmd := exec.Command("pnpm", playwrightArgs...) + playwrightCmd := exec.CommandContext(ctx, "pnpm", playwrightArgs...) playwrightCmd.Dir = "frontend" playwrightCmd.Env = append(os.Environ(), fmt.Sprintf("API_URL=%s/", apiBase), @@ -591,7 +626,7 @@ func (Test) E2E(args string) error { type Check mg.Namespace // GotSwag checks if the swagger docs need to be re-generated from the code annotations -func (Check) GotSwag() { +func (Check) GotSwag(ctx context.Context) error { mg.Deps(initVars) // The check is pretty cheaply done: We take the hash of the swagger.json file, generate the docs, // hash the file again and compare the two hashes to see if anything changed. If that's the case, @@ -601,27 +636,26 @@ func (Check) GotSwag() { // docs after the check. This behaviour is good enough for ci though. oldHash, err := calculateSha256FileHash("./pkg/swagger/swagger.json") if err != nil { - fmt.Printf("Error getting old hash of the swagger docs: %s", err) - os.Exit(1) + return fmt.Errorf("error getting old hash of the swagger docs: %w", err) } - (Generate{}).SwaggerDocs() + if generateErr := (Generate{}).SwaggerDocs(ctx); generateErr != nil { + return generateErr + } newHash, err := calculateSha256FileHash("./pkg/swagger/swagger.json") if err != nil { - fmt.Printf("Error getting new hash of the swagger docs: %s", err) - os.Exit(1) + return fmt.Errorf("error getting new hash of the swagger docs: %w", err) } if oldHash != newHash { - fmt.Println("Swagger docs are not up to date.") - fmt.Println("Please run 'mage generate:swagger-docs' and commit the result.") - os.Exit(1) + return fmt.Errorf("swagger docs are not up to date: run 'mage generate:swagger-docs' and commit the result") } + return nil } // Translations checks if all translation keys used in the code exist in the English translation file -func (Check) Translations() { +func (Check) Translations() error { mg.Deps(initVars) fmt.Println("Checking for missing translation keys...") @@ -629,8 +663,7 @@ func (Check) Translations() { translationFile := "./pkg/i18n/lang/en.json" translations, err := loadTranslations(translationFile) if err != nil { - fmt.Printf("Error loading translations: %v\n", err) - os.Exit(1) + return fmt.Errorf("error loading translations: %w", err) } fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile) @@ -638,8 +671,7 @@ func (Check) Translations() { // Extract keys from codebase keys, err := walkCodebaseForTranslationKeys(".") if err != nil { - fmt.Printf("Error walking codebase: %v\n", err) - os.Exit(1) + return fmt.Errorf("error walking codebase: %w", err) } fmt.Printf("Found %d translation keys in the codebase\n", len(keys)) @@ -654,17 +686,18 @@ func (Check) Translations() { // Print results if len(missingKeys) > 0 { - fmt.Printf("\nFound %d missing translation keys:\n", len(missingKeys)) + var errs []error for key, occurrences := range missingKeys { - fmt.Printf("\nKey: %s\n", key) + var keyErrs []error for _, occurrence := range occurrences { - fmt.Printf(" - %s:%d\n", occurrence.FilePath, occurrence.Line) + keyErrs = append(keyErrs, fmt.Errorf("- %s:%d", occurrence.FilePath, occurrence.Line)) } + errs = append(errs, fmt.Errorf("missing key %s in files:\n%w", key, errors.Join(keyErrs...))) } - os.Exit(1) - } else { - printSuccess("All translation keys are present in the translation file!") + return fmt.Errorf("found %d missing translation keys:\n%w", len(missingKeys), errors.Join(errs...)) } + printSuccess("All translation keys are present in the translation file!") + return nil } // TranslationKey represents a translation key found in the code @@ -678,12 +711,12 @@ type TranslationKey struct { func loadTranslations(filePath string) (map[string]bool, error) { data, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("error reading translation file: %v", err) + return nil, fmt.Errorf("error reading translation file: %w", err) } - var translationsMap map[string]interface{} + var translationsMap map[string]any if err := json.Unmarshal(data, &translationsMap); err != nil { - return nil, fmt.Errorf("error parsing JSON: %v", err) + return nil, fmt.Errorf("error parsing JSON: %w", err) } // Flatten the nested structure @@ -694,7 +727,7 @@ func loadTranslations(filePath string) (map[string]bool, error) { } // flattenTranslations recursively flattens a nested map structure into a flat map with dot-separated keys -func flattenTranslations(prefix string, src map[string]interface{}, dest map[string]bool) { +func flattenTranslations(prefix string, src map[string]any, dest map[string]bool) { for k, v := range src { key := k if prefix != "" { @@ -704,7 +737,7 @@ func flattenTranslations(prefix string, src map[string]interface{}, dest map[str switch vv := v.(type) { case string: dest[key] = true - case map[string]interface{}: + case map[string]any: flattenTranslations(key, vv, dest) } } @@ -747,7 +780,7 @@ func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) { // Read the file content content, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("error reading file %s: %v", filePath, err) + return nil, fmt.Errorf("error reading file %s: %w", filePath, err) } var keys []TranslationKey @@ -777,23 +810,26 @@ func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) { return keys, nil } -func checkGolangCiLintInstalled() { - mg.Deps(initVars) - if err := exec.Command("golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") { - fmt.Println("Please manually install golangci-lint by running") - fmt.Println("curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0") - os.Exit(1) +func checkGolangCiLintInstalled(ctx context.Context) error { + mg.Deps(initVars, ensureFrontendDistExists) + if err := exec.CommandContext(ctx, "golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") { + return fmt.Errorf("golangci-lint executable failed to run, please manually install golangci-lint by running the command: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0") } + return nil } -func (Check) Golangci() error { - checkGolangCiLintInstalled() - return runAndStreamOutput("golangci-lint", "run") +func (Check) Golangci(ctx context.Context) error { + if err := checkGolangCiLintInstalled(ctx); err != nil { + return err + } + return runAndStreamOutput(ctx, "golangci-lint", "run") } -func (Check) GolangciFix() error { - checkGolangCiLintInstalled() - return runAndStreamOutput("golangci-lint", "run", "--fix") +func (Check) GolangciFix(ctx context.Context) error { + if err := checkGolangCiLintInstalled(ctx); err != nil { + return err + } + return runAndStreamOutput(ctx, "golangci-lint", "run", "--fix") } // All runs golangci and the swagger test in parallel @@ -809,9 +845,9 @@ func (Check) All() { type Build mg.Namespace // Clean cleans all build, executable and bindata files -func (Build) Clean() error { +func (Build) Clean(ctx context.Context) error { mg.Deps(initVars) - if err := exec.Command("go", "clean", "./...").Run(); err != nil { + if err := exec.CommandContext(ctx, "go", "clean", "./...").Run(); err != nil { return err } if err := os.Remove(Executable); err != nil && !os.IsNotExist(err) { @@ -827,29 +863,9 @@ func (Build) Clean() error { } // Build builds a vikunja binary, ready to run -func (Build) Build() error { - mg.Deps(initVars) - // Check if the frontend dist folder exists - distPath := filepath.Join("frontend", "dist") - if _, err := os.Stat(distPath); os.IsNotExist(err) { - if err := os.MkdirAll(distPath, 0o755); err != nil { - fmt.Printf("Error creating %s: %s\n", distPath, err) - os.Exit(1) - } - } - - indexFile := filepath.Join(distPath, "index.html") - if _, err := os.Stat(indexFile); os.IsNotExist(err) { - f, err := os.Create(indexFile) - if err != nil { - fmt.Printf("Error creating %s: %s\n", indexFile, err) - os.Exit(1) - } - f.Close() - fmt.Printf("Warning: %s not found, created empty file\n", indexFile) - } - - return runAndStreamOutput("go", "build", goDetectVerboseFlag(), "-tags", Tags, "-ldflags", "-s -w "+Ldflags, "-o", Executable) +func (Build) Build(ctx context.Context) error { + mg.Deps(initVars, ensureFrontendDistExists) + return runAndStreamOutput(ctx, "go", "build", goDetectVerboseFlag(), "-tags", Tags, "-ldflags", "-s -w "+Ldflags, "-o", Executable) } func (Build) SaveVersionToFile() error { @@ -881,9 +897,9 @@ func (Release) Release(ctx context.Context) error { // Run compiling in parallel to speed it up errs, _ := errgroup.WithContext(ctx) - errs.Go((Release{}).Windows) - errs.Go((Release{}).Linux) - errs.Go((Release{}).Darwin) + errgroupGoWithContext(ctx, errs, (Release{}).Windows) + errgroupGoWithContext(ctx, errs, (Release{}).Linux) + errgroupGoWithContext(ctx, errs, (Release{}).Darwin) if err := errs.Wait(); err != nil { return err } @@ -900,13 +916,19 @@ func (Release) Release(ctx context.Context) error { if err := (Release{}).OsPackage(); err != nil { return err } - if err := (Release{}).Zip(); err != nil { + if err := (Release{}).Zip(ctx); err != nil { return err } return nil } +func errgroupGoWithContext(ctx context.Context, errs *errgroup.Group, do func(context.Context) error) { + errs.Go(func() error { + return do(ctx) + }) +} + // Dirs creates all directories needed to release vikunja func (Release) Dirs() error { for _, d := range []string{"binaries", "release", "zip"} { @@ -917,17 +939,21 @@ func (Release) Dirs() error { return nil } -func prepareXgo() error { +func prepareXgo(ctx context.Context) error { mg.Deps(initVars) - checkAndInstallGoTool("xgo", "src.techknowlogick.com/xgo") + if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { + return err + } fmt.Println("Pulling latest xgo docker image...") - return runAndStreamOutput("docker", "pull", "ghcr.io/techknowlogick/xgo:latest") + return runAndStreamOutput(ctx, "docker", "pull", "ghcr.io/techknowlogick/xgo:latest") } -func runXgo(targets string) error { +func runXgo(ctx context.Context, targets string) error { mg.Deps(initVars) - checkAndInstallGoTool("xgo", "src.techknowlogick.com/xgo") + if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { + return err + } extraLdflags := `-linkmode external -extldflags "-static" ` @@ -940,7 +966,7 @@ func runXgo(targets string) error { outName = Executable + "-" + Version } - if err := runAndStreamOutput("xgo", + if err := runAndStreamOutput(ctx, "xgo", "-dest", "./"+DIST+"/binaries", "-tags", "netgo "+Tags, "-ldflags", extraLdflags+Ldflags, @@ -951,6 +977,9 @@ func runXgo(targets string) error { } if os.Getenv("DRONE_WORKSPACE") != "" { return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } // Skip directories if info.IsDir() { return nil @@ -963,12 +992,12 @@ func runXgo(targets string) error { } // Windows builds binaries for windows -func (Release) Windows() error { - return runXgo("windows/*") +func (Release) Windows(ctx context.Context) error { + return runXgo(ctx, "windows/*") } // Linux builds binaries for linux -func (Release) Linux() error { +func (Release) Linux(ctx context.Context) error { targets := []string{ "linux/amd64", "linux/arm-5", @@ -981,15 +1010,15 @@ func (Release) Linux() error { "linux/mips64le", "linux/riscv64", } - return runXgo(strings.Join(targets, ",")) + return runXgo(ctx, strings.Join(targets, ",")) } // Darwin builds binaries for darwin -func (Release) Darwin() error { - return runXgo("darwin-10.15/*") +func (Release) Darwin(ctx context.Context) error { + return runXgo(ctx, "darwin-10.15/*") } -func (Release) Xgo(target string) error { +func (Release) Xgo(ctx context.Context, target string) error { parts := strings.Split(target, "/") if len(parts) < 2 { return fmt.Errorf("invalid target") @@ -1000,7 +1029,7 @@ func (Release) Xgo(target string) error { variant = "-" + strings.ReplaceAll(parts[2], "v", "") } - return runXgo(parts[0] + "/" + parts[1] + variant) + return runXgo(ctx, parts[0]+"/"+parts[1]+variant) } // Compress compresses the built binaries in dist/binaries/ to reduce their filesize @@ -1009,7 +1038,10 @@ func (Release) Compress(ctx context.Context) error { errs, _ := errgroup.WithContext(ctx) - filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { + walkErr := filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } // Only executable files if !strings.Contains(info.Name(), Executable) { return nil @@ -1025,21 +1057,26 @@ func (Release) Compress(ctx context.Context) error { // Runs compressing in parallel since upx is single-threaded errs.Go(func() error { - if err := runAndStreamOutput("chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not. + if err := runAndStreamOutput(ctx, "chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not. return err } - return runAndStreamOutput("upx", "-9", path) + return runAndStreamOutput(ctx, "upx", "-9", path) }) return nil }) - + if walkErr != nil { + return walkErr + } return errs.Wait() } // Copy copies all built binaries to dist/release/ in preparation for creating the os packages func (Release) Copy() error { return filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } // Only executable files if !strings.Contains(info.Name(), Executable) { return nil @@ -1087,6 +1124,9 @@ func (Release) OsPackage() error { // over the newly created files, creating some kind of endless loop. bins := make(map[string]os.FileInfo) if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } if strings.Contains(info.Name(), ".sha256") || info.IsDir() { return nil } @@ -1120,7 +1160,7 @@ func (Release) OsPackage() error { } // Zip creates a zip file from all os-package folders in dist/release -func (Release) Zip() error { +func (Release) Zip(ctx context.Context) error { rootDir, err := os.Getwd() if err != nil { return fmt.Errorf("could not get working directory: %w", err) @@ -1138,7 +1178,7 @@ func (Release) Zip() error { fmt.Printf("Zipping %s...\n", info.Name()) zipFile := filepath.Join(rootDir, DIST, "zip", info.Name()+".zip") - c := exec.Command("zip", "-r", zipFile, ".", "-i", "*") + c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") //nolint:gosec // This mage task creates zips of every directory recursively, it must use the directory name in the resulting file path to distinguish output files. c.Dir = path out, err := c.Output() fmt.Print(string(out)) @@ -1151,9 +1191,9 @@ func (Release) Zip() error { } // Reprepro creates a debian repo structure -func (Release) Reprepro() error { +func (Release) Reprepro(ctx context.Context) error { mg.Deps(setVersion, setBinLocation) - return runAndStreamOutput("reprepro_expect", "debian", "includedeb", "buster", "./"+DIST+"/os-packages/"+Executable+"_"+strings.ReplaceAll(VersionNumber, "v0", "0")+"_amd64.deb") + return runAndStreamOutput(ctx, "reprepro_expect", "debian", "includedeb", "buster", "./"+DIST+"/os-packages/"+Executable+"_"+strings.ReplaceAll(VersionNumber, "v0", "0")+"_amd64.deb") } // PrepareNFPMConfig prepares the nfpm config @@ -1180,7 +1220,7 @@ func (Release) PrepareNFPMConfig() error { } // Packages creates deb, rpm and apk packages -func (Release) Packages() error { +func (Release) Packages(ctx context.Context) error { mg.Deps(initVars) var err error @@ -1188,15 +1228,13 @@ func (Release) Packages() error { if binpath == "" { binpath = "nfpm" } - err = exec.Command(binpath).Run() + err = exec.CommandContext(ctx, binpath).Run() if err != nil && strings.Contains(err.Error(), "executable file not found") { binpath = "/usr/bin/nfpm" - err = exec.Command(binpath).Run() + err = exec.CommandContext(ctx, binpath).Run() } if err != nil && strings.Contains(err.Error(), "executable file not found") { - fmt.Println("Please manually install nfpm by running") - fmt.Println("curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin") - os.Exit(1) + return fmt.Errorf("executable %s not found: please manually install nfpm by running the command: curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin", binpath) } err = (Release{}).PrepareNFPMConfig() @@ -1209,13 +1247,13 @@ func (Release) Packages() error { return err } - if err := runAndStreamOutput(binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil { + if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil { return err } - if err := runAndStreamOutput(binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil { + if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil { return err } - if err := runAndStreamOutput(binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil { + if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil { return err } @@ -1362,7 +1400,7 @@ func (s *` + name + `) Handle(msg *message.Message) (err error) { } scanner := bufio.NewScanner(file) - var idx int64 = 0 + var idx int64 for scanner.Scan() { if scanner.Text() == "}" { // idx -= int64(len(scanner.Text())) @@ -1389,9 +1427,15 @@ func (s *` + name + `) Handle(msg *message.Message) (err error) { if err != nil { return err } - f.Seek(idx, 0) - f.Write([]byte(registerListenerCode)) - f.Write(remainder) + if _, err := f.Seek(idx, 0); err != nil { + return err + } + if _, err := f.Write([]byte(registerListenerCode)); err != nil { + return err + } + if _, err := f.Write(remainder); err != nil { + return err + } /////// // Append the listener code @@ -1429,7 +1473,7 @@ func (n *` + name + `) ToMail(lang string) *notifications.Mail { } // ToDB returns the ` + name + ` notification in a format which can be saved in the db -func (n *` + name + `) ToDB() interface{} { +func (n *` + name + `) ToDB() any { return nil } @@ -1454,16 +1498,18 @@ type Generate mg.Namespace const DefaultConfigYAMLSamplePath = "config.yml.sample" // SwaggerDocs generates the swagger docs from the code annotations -func (Generate) SwaggerDocs() error { +func (Generate) SwaggerDocs(ctx context.Context) error { mg.Deps(initVars) - checkAndInstallGoTool("swag", "github.com/swaggo/swag/cmd/swag") - return runAndStreamOutput("swag", "init", "-g", "./pkg/routes/routes.go", "--parseDependency", "-d", ".", "-o", "./pkg/swagger") + if err := checkAndInstallGoTool(ctx, "swag", "github.com/swaggo/swag/cmd/swag"); err != nil { + return err + } + return runAndStreamOutput(ctx, "swag", "init", "-g", "./pkg/routes/routes.go", "--parseDependency", "-d", ".", "-o", "./pkg/swagger") } type ConfigNode struct { Key string `json:"key,omitempty"` - Value interface{} `json:"default_value,omitempty"` + Value any `json:"default_value,omitempty"` Comment string `json:"comment,omitempty"` Children []*ConfigNode `json:"children,omitempty"` } @@ -1514,14 +1560,15 @@ func convertConfigJSONToYAML(node *ConfigNode, indent int, isTopLevel bool, pare isProviders := node.Key == "providers" && parentKey == "openid" isArray := len(node.Children) > 0 && node.Children[0].Key == "" for i, child := range node.Children { - if isProviders { + switch { + case isProviders: writeComment(child.Comment, indent+1) writeLine("-", indent+1) result.WriteString(convertConfigJSONToYAML(child, indent+1, false, node.Key, commentOut)) - } else if isArray { + case isArray: writeComment(child.Comment, indent+1) writeLine("- "+formatValue(child.Value), indent+1) - } else { + default: result.WriteString(convertConfigJSONToYAML(child, indent+1, false, node.Key, commentOut)) } if i == len(node.Children)-1 && !isProviders && !isArray { @@ -1533,7 +1580,7 @@ func convertConfigJSONToYAML(node *ConfigNode, indent int, isTopLevel bool, pare return result.String() } -func formatValue(value interface{}) string { +func formatValue(value any) string { switch v := value.(type) { case string: if intValue, err := strconv.Atoi(v); err == nil { @@ -1575,7 +1622,7 @@ func generateConfigYAMLFromJSON(yamlPath string, commented bool) { yamlData := convertConfigJSONToYAML(&root, -1, true, "", commented) - err = os.WriteFile(yamlPath, []byte(yamlData), 0o644) + err = os.WriteFile(yamlPath, []byte(yamlData), 0o600) if err != nil { fmt.Println("Error writing YAML file:", err) return @@ -1594,7 +1641,7 @@ func (Generate) ConfigYAML(commented bool) { // The second argument is a path to a plan file that will be copied to the new worktree (pass "" to skip). // The worktree is created in the parent directory (../). // It also copies the current config.yml with an updated rootpath, and initializes the frontend. -func (Dev) PrepareWorktree(name string, planPath string) error { +func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) error { if name == "" { return fmt.Errorf("name is required: mage dev:prepare-worktree ") } @@ -1605,7 +1652,7 @@ func (Dev) PrepareWorktree(name string, planPath string) error { fmt.Printf("Creating worktree at %s with branch %s...\n", worktreePath, name) // Create the git worktree - cmd := exec.Command("git", "worktree", "add", worktreePath, "-b", name) + cmd := exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, "-b", name) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -1631,7 +1678,7 @@ func (Dev) PrepareWorktree(name string, planPath string) error { re2 := regexp.MustCompile(`(?m)^(\s*rootpath:\s*)(/[^\s\n]+)`) newConfig = re2.ReplaceAllString(newConfig, `${1}"`+worktreePath+`"`) - if err := os.WriteFile(configDst, []byte(newConfig), 0o644); err != nil { + if err := os.WriteFile(configDst, []byte(newConfig), 0o600); err != nil { return fmt.Errorf("failed to write config.yml: %w", err) } printSuccess("Config copied with updated rootpath!") @@ -1686,7 +1733,7 @@ func (Dev) PrepareWorktree(name string, planPath string) error { frontendDir := filepath.Join(worktreePath, "frontend") // Run pnpm install - pnpmCmd := exec.Command("pnpm", "i") + pnpmCmd := exec.CommandContext(ctx, "pnpm", "i") pnpmCmd.Dir = frontendDir pnpmCmd.Stdout = os.Stdout pnpmCmd.Stderr = os.Stderr @@ -1695,7 +1742,7 @@ func (Dev) PrepareWorktree(name string, planPath string) error { } // Run patch-sass-embedded (shell alias from devenv) - patchCmd := exec.Command("bash", "-ic", "patch-sass-embedded") + patchCmd := exec.CommandContext(ctx, "bash", "-ic", "patch-sass-embedded") patchCmd.Dir = frontendDir patchCmd.Stdout = os.Stdout patchCmd.Stderr = os.Stderr @@ -1714,8 +1761,8 @@ func (Dev) PrepareWorktree(name string, planPath string) error { } // printReleaseStats prints commit statistics for the range between two refs. -func printReleaseStats(fromRef, toRef string) error { - output, err := runCmdWithOutput("git", "log", fromRef+".."+toRef, "--oneline") +func printReleaseStats(ctx context.Context, fromRef, toRef string) error { + output, err := runGitCommandWithOutput(ctx, "log", fromRef+".."+toRef, "--oneline") if err != nil { return fmt.Errorf("failed to get commit log: %w", err) } @@ -1769,7 +1816,7 @@ func printReleaseStats(fromRef, toRef string) error { // TagRelease creates a new release tag with changelog. // It updates the version badge in README.md, generates changelog using git-cliff, // commits the changes, and creates an annotated tag. -func (Dev) TagRelease(version string) error { +func (Dev) TagRelease(ctx context.Context, version string) error { if version == "" { return fmt.Errorf("version is required: mage dev:tag-release ") } @@ -1782,7 +1829,7 @@ func (Dev) TagRelease(version string) error { fmt.Printf("Creating release %s...\n", version) // Get the last tag - lastTagBytes, err := runCmdWithOutput("git", "describe", "--tags", "--abbrev=0") + lastTagBytes, err := runGitCommandWithOutput(ctx, "describe", "--tags", "--abbrev=0") if err != nil { return fmt.Errorf("failed to get last tag: %w", err) } @@ -1790,13 +1837,13 @@ func (Dev) TagRelease(version string) error { fmt.Printf("Last tag: %s\n", lastTag) // Print commit statistics - if err := printReleaseStats(lastTag, "HEAD"); err != nil { + if err := printReleaseStats(ctx, lastTag, "HEAD"); err != nil { fmt.Printf("Warning: could not print release stats: %v\n", err) } // Generate changelog using git cliff fmt.Println("Generating changelog...") - changelogBytes, err := runCmdWithOutput("git", "cliff", lastTag+"..HEAD", "--tag", version) + changelogBytes, err := runGitCommandWithOutput(ctx, "cliff", lastTag+"..HEAD", "--tag", version) if err != nil { return fmt.Errorf("failed to generate changelog: %w", err) } @@ -1826,12 +1873,12 @@ func (Dev) TagRelease(version string) error { // Commit the changes fmt.Println("Committing changes...") commitMsg := fmt.Sprintf("chore: %s release preparations", version) - cmd := exec.Command("git", "add", "README.md", "CHANGELOG.md", "frontend/package.json") + cmd := exec.CommandContext(ctx, "git", "add", "README.md", "CHANGELOG.md", "frontend/package.json") if err := cmd.Run(); err != nil { return fmt.Errorf("failed to stage files: %w", err) } - cmd = exec.Command("git", "commit", "-m", commitMsg) + cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -1843,7 +1890,7 @@ func (Dev) TagRelease(version string) error { // Create the annotated tag fmt.Printf("Creating tag %s...\n", version) - cmd = exec.Command("git", "tag", "-a", version, "-m", tagMessage) + cmd = exec.CommandContext(ctx, "git", "tag", "-a", version, "-m", tagMessage) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -1878,7 +1925,8 @@ func cleanupChangelog(changelog string) string { strings.HasPrefix(trimmedLine, "### ") || trimmedLine == "" - if isNewEntry { + switch { + case isNewEntry: // Flush the current entry if any if currentEntry.Len() > 0 { entryStr := strings.TrimSpace(currentEntry.String()) @@ -1890,22 +1938,23 @@ func cleanupChangelog(changelog string) string { } // Start a new entry or add empty line/header - if trimmedLine == "" { + switch { + case trimmedLine == "": // Only add empty line if the previous line wasn't empty if len(cleanedLines) > 0 && cleanedLines[len(cleanedLines)-1] != "" { cleanedLines = append(cleanedLines, "") } - } else if strings.HasPrefix(trimmedLine, "## ") || strings.HasPrefix(trimmedLine, "### ") { + case strings.HasPrefix(trimmedLine, "## ") || strings.HasPrefix(trimmedLine, "### "): // Headers are never duplicates cleanedLines = append(cleanedLines, trimmedLine) - } else { + default: currentEntry.WriteString(trimmedLine) } - } else if currentEntry.Len() > 0 { + case currentEntry.Len() > 0: // This is a continuation of the current entry currentEntry.WriteString(" ") currentEntry.WriteString(trimmedLine) - } else if trimmedLine != "" { + case trimmedLine != "": // Standalone line that's not part of an entry if !seenLines[trimmedLine] { cleanedLines = append(cleanedLines, trimmedLine) @@ -1940,7 +1989,7 @@ func updateReadmeBadge(version string) error { re := regexp.MustCompile(`(download-)(v[0-9a-zA-Z.]+)(-brightgreen)`) newContent := re.ReplaceAllString(string(content), "${1}"+badgeVersion+"${3}") - if err := os.WriteFile(readmePath, []byte(newContent), 0o644); err != nil { + if err := os.WriteFile(readmePath, []byte(newContent), 0o600); err != nil { return fmt.Errorf("failed to write README.md: %w", err) } @@ -1961,7 +2010,7 @@ func updateFrontendPackageJSON(version string) error { re := regexp.MustCompile(`("version"\s*:\s*")([^"]+)(")`) newContent := re.ReplaceAllString(string(content), "${1}"+npmVersion+"${3}") - if err := os.WriteFile(pkgPath, []byte(newContent), 0o644); err != nil { + if err := os.WriteFile(pkgPath, []byte(newContent), 0o600); err != nil { return fmt.Errorf("failed to write %s: %w", pkgPath, err) } @@ -1996,7 +2045,7 @@ func prependChangelog(newChangelog string) error { strings.TrimSpace(newChangelog) + "\n" + existingVersions - if err := os.WriteFile(changelogPath, []byte(newContent), 0o644); err != nil { + if err := os.WriteFile(changelogPath, []byte(newContent), 0o600); err != nil { return fmt.Errorf("failed to write CHANGELOG.md: %w", err) } @@ -2010,11 +2059,12 @@ func prepareTagMessage(changelog string) string { for _, line := range lines { // Remove ## and ### prefixes - if strings.HasPrefix(line, "### ") { + switch { + case strings.HasPrefix(line, "### "): result = append(result, strings.TrimPrefix(line, "### ")) - } else if strings.HasPrefix(line, "## ") { + case strings.HasPrefix(line, "## "): result = append(result, strings.TrimPrefix(line, "## ")) - } else { + default: result = append(result, line) } } @@ -2025,7 +2075,7 @@ func prepareTagMessage(changelog string) string { type Plugins mg.Namespace // Build compiles a Go plugin at the provided path. -func (Plugins) Build(pathToSourceFiles string) error { +func (Plugins) Build(ctx context.Context, pathToSourceFiles string) error { mg.Deps(initVars) if pathToSourceFiles == "" { return fmt.Errorf("please provide a plugin path") @@ -2035,11 +2085,11 @@ func (Plugins) Build(pathToSourceFiles string) error { if !strings.HasPrefix(pathToSourceFiles, "/") { absPath, err := filepath.Abs(pathToSourceFiles) if err != nil { - return fmt.Errorf("failed to resolve absolute path: %v", err) + return fmt.Errorf("failed to resolve absolute path: %w", err) } pathToSourceFiles = absPath } out := filepath.Join("plugins", filepath.Base(pathToSourceFiles)+".so") - return runAndStreamOutput("go", "build", "-buildmode=plugin", "-tags", Tags, "-o", out, pathToSourceFiles) + return runAndStreamOutput(ctx, "go", "build", "-buildmode=plugin", "-tags", Tags, "-o", out, pathToSourceFiles) } diff --git a/pkg/cmd/user.go b/pkg/cmd/user.go index 7a89466ca..902ac6317 100644 --- a/pkg/cmd/user.go +++ b/pkg/cmd/user.go @@ -361,7 +361,11 @@ var userDeleteCmd = &cobra.Command{ if err != nil { log.Fatalf("could not read confirmation message: %s", err) } - if text != "YES, I CONFIRM\n" { + + // On Windows a newline is \r\n, while on Linux it is only \n. + text = strings.TrimRight(text, "\r\n") + + if text != "YES, I CONFIRM" { log.Fatalf("invalid confirmation message") } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 6eaab3acd..a1a60aa7a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -70,6 +70,8 @@ const ( ServiceEnablePublicTeams Key = `service.enablepublicteams` ServiceBcryptRounds Key = `service.bcryptrounds` ServiceEnableOpenIDTeamUserOnlySearch Key = `service.enableopenidteamusersearch` + ServiceIPExtractionMethod Key = `service.ipextractionmethod` + ServiceTrustedProxies Key = `service.trustedproxies` SentryEnabled Key = `sentry.enabled` SentryDsn Key = `sentry.dsn` @@ -211,10 +213,11 @@ const ( DefaultSettingsTimezone Key = `defaultsettings.timezone` DefaultSettingsOverdueTaskRemindersTime Key = `defaultsettings.overdue_tasks_reminders_time` - WebhooksEnabled Key = `webhooks.enabled` - WebhooksTimeoutSeconds Key = `webhooks.timeoutseconds` - WebhooksProxyURL Key = `webhooks.proxyurl` - WebhooksProxyPassword Key = `webhooks.proxypassword` + WebhooksEnabled Key = `webhooks.enabled` + WebhooksTimeoutSeconds Key = `webhooks.timeoutseconds` + WebhooksProxyURL Key = `webhooks.proxyurl` + WebhooksProxyPassword Key = `webhooks.proxypassword` + WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips` AutoTLSEnabled Key = `autotls.enabled` AutoTLSEmail Key = `autotls.email` @@ -287,37 +290,33 @@ func (k Key) setDefault(i interface{}) { viper.SetDefault(string(k), i) } -// Tries different methods to figure out the binary folder. -// Copied and adopted from https://github.com/speedata/publisher/commit/3b668668d57edef04ea854d5bbd58f83eb1b799f -func getBinaryDirLocation() string { - // First, check if the standard library gives us the path. This will work 99% of the time. - ex, err := os.Executable() - if err == nil { +// getRootpathLocation determines the default root path for Vikunja data. +// It prefers the current working directory, which respects systemd's +// WorkingDirectory= setting and is the most intuitive default. +// Falls back to the binary's directory if Getwd fails. +func getRootpathLocation() string { + // Prefer working directory — this respects systemd WorkingDirectory= + // and is the intuitive default for most deployment scenarios. + if wd, err := os.Getwd(); err == nil { + return wd + } + + // Fall back to the binary's directory. + if ex, err := os.Executable(); err == nil { return filepath.Dir(ex) } - // Then check if the binary was run with a full path and use that if that's the case. - if strings.Contains(os.Args[0], "/") { - binDir, err := filepath.Abs(filepath.Dir(os.Args[0])) - if err != nil { - log.Fatal(err) - } - return binDir - } - + // Last resort: search $PATH. exeSuffix := "" if runtime.GOOS == "windows" { exeSuffix = ".exe" } - - // All else failing, search for a vikunja binary in the current $PATH. - // This can give wrong results. - exeLocation, err := exec.LookPath("vikunja" + exeSuffix) - if err != nil { - log.Fatal(err) + if exeLocation, err := exec.LookPath("vikunja" + exeSuffix); err == nil { + return filepath.Dir(exeLocation) } - return filepath.Dir(exeLocation) + log.Fatal("Could not determine root path. Set service.rootpath in your config.") + return "" } // InitDefaultConfig sets default config values @@ -339,7 +338,7 @@ func InitDefaultConfig() { ServicePublicURL.setDefault("") ServiceEnableCaldav.setDefault(true) - ServiceRootpath.setDefault(getBinaryDirLocation()) + ServiceRootpath.setDefault(getRootpathLocation()) ServiceMaxItemsPerPage.setDefault(50) ServiceMotd.setDefault("") ServiceEnableLinkSharing.setDefault(true) @@ -356,6 +355,8 @@ func InitDefaultConfig() { ServiceEnablePublicTeams.setDefault(false) ServiceBcryptRounds.setDefault(11) ServiceEnableOpenIDTeamUserOnlySearch.setDefault(false) + ServiceIPExtractionMethod.setDefault("direct") + ServiceTrustedProxies.setDefault("") // Sentry SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944") @@ -383,7 +384,7 @@ func InitDefaultConfig() { DatabaseUser.setDefault("vikunja") DatabasePassword.setDefault("") DatabaseDatabase.setDefault("vikunja") - DatabasePath.setDefault(filepath.Join(ServiceRootpath.GetString(), "vikunja.db")) + DatabasePath.setDefault(ResolvePath("vikunja.db")) DatabaseMaxOpenConnections.setDefault(100) DatabaseMaxIdleConnections.setDefault(50) DatabaseMaxConnectionLifetime.setDefault(10000) @@ -419,7 +420,7 @@ func InitDefaultConfig() { LogDatabase.setDefault("off") LogDatabaseLevel.setDefault("WARNING") LogHTTP.setDefault("stdout") - LogPath.setDefault(ServiceRootpath.GetString() + "/logs") + LogPath.setDefault(ResolvePath("logs")) LogEvents.setDefault("off") LogEventsLevel.setDefault("INFO") LogMail.setDefault("off") @@ -470,11 +471,22 @@ func InitDefaultConfig() { // Webhook WebhooksEnabled.setDefault(true) WebhooksTimeoutSeconds.setDefault(30) + WebhooksAllowNonRoutableIPs.setDefault(false) // AutoTLS AutoTLSRenewBefore.setDefault("720h") // 30days in hours // Plugins PluginsEnabled.setDefault(false) - PluginsDir.setDefault(filepath.Join(ServiceRootpath.GetString(), "plugins")) + PluginsDir.setDefault(ResolvePath("plugins")) +} + +// ResolvePath resolves a path relative to service.rootpath. +// If the path is already absolute, it is returned as-is (cleaned). +// If the path is relative (or empty), it is joined with service.rootpath. +func ResolvePath(p string) string { + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + return filepath.Join(ServiceRootpath.GetString(), p) } func GetConfigValueFromFile(configKey string) string { @@ -486,9 +498,7 @@ func GetConfigValueFromFile(configKey string) string { return "" } - if !strings.HasPrefix(valuePath, "/") { - valuePath = path.Join(ServiceRootpath.GetString(), valuePath) - } + valuePath = ResolvePath(valuePath) contents, err := os.ReadFile(valuePath) if err == nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..b50d68b37 --- /dev/null +++ b/pkg/config/config_test.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 config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetRootpathLocation(t *testing.T) { + // The function should return the current working directory + expected, err := os.Getwd() + require.NoError(t, err) + + result := getRootpathLocation() + assert.Equal(t, expected, result) +} + +func TestResolvePath(t *testing.T) { + // Save and restore rootpath + original := ServiceRootpath.GetString() + defer ServiceRootpath.Set(original) + ServiceRootpath.Set("/var/lib/vikunja") + + tests := []struct { + name string + input string + expected string + }{ + { + name: "absolute path returned as-is", + input: "/etc/vikunja/config.yml", + expected: "/etc/vikunja/config.yml", + }, + { + name: "relative path joined with rootpath", + input: "files", + expected: "/var/lib/vikunja/files", + }, + { + name: "relative subdir path joined with rootpath", + input: "data/vikunja.db", + expected: "/var/lib/vikunja/data/vikunja.db", + }, + { + name: "empty string returns rootpath", + input: "", + expected: "/var/lib/vikunja", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ResolvePath(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index b17b5b76a..e8298d7ed 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -420,3 +420,12 @@ index: 32 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 +- id: 48 + title: 'Landingpages update' + description: 'Update all landingpages with new branding' + done: false + created_by_id: 1 + project_id: 1 + index: 33 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 diff --git a/pkg/db/fixtures/totp.yml b/pkg/db/fixtures/totp.yml new file mode 100644 index 000000000..6eece9462 --- /dev/null +++ b/pkg/db/fixtures/totp.yml @@ -0,0 +1,11 @@ +- id: 1 + user_id: 10 + secret: 'JBSWY3DPEHPK3PXP' + enabled: true + url: 'otpauth://totp/Vikunja:user10?secret=JBSWY3DPEHPK3PXP&issuer=Vikunja' +# user1 has TOTP enrolled but not yet enabled — used by existing TOTP enrollment tests +- id: 2 + user_id: 1 + secret: 'HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + enabled: false + url: 'otpauth://totp/Vikunja:user1?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=Vikunja' diff --git a/pkg/db/fixtures/user_tokens.yml b/pkg/db/fixtures/user_tokens.yml index f676297bd..63362c632 100644 --- a/pkg/db/fixtures/user_tokens.yml +++ b/pkg/db/fixtures/user_tokens.yml @@ -22,3 +22,15 @@ token: 'deletiontesttoken' kind: 3 created: 2021-07-12 00:00:14 +- + id: 5 + user_id: 17 + token: 'disableduserpasswordresettoken' + kind: 1 + created: 2024-01-01 00:00:00 +- + id: 6 + user_id: 10 + token: '$2a$04$DT./vBYVmwfc8KlWOyLmNOF4KJLahG31L1eSzg45RRZKPhfjojSDa' + kind: 4 + created: 2024-01-01 00:00:00 diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index b2cc573c3..9bbee147e 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -127,3 +127,12 @@ default_project_id: 37 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +# Disabled user for security tests +- id: 17 + username: 'user17' + password: '$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6' # 12345678 + email: 'user17@example.com' + status: 2 + issuer: local + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/users_projects.yml b/pkg/db/fixtures/users_projects.yml index 22a32ae8b..26297deb8 100644 --- a/pkg/db/fixtures/users_projects.yml +++ b/pkg/db/fixtures/users_projects.yml @@ -112,3 +112,9 @@ permission: 0 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 20 + user_id: 15 + project_id: 35 + permission: 0 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/webhooks.yml b/pkg/db/fixtures/webhooks.yml new file mode 100644 index 000000000..0655d9288 --- /dev/null +++ b/pkg/db/fixtures/webhooks.yml @@ -0,0 +1,7 @@ +- id: 1 + target_url: "https://example.com/webhook-fixture" + events: '["task.updated"]' + project_id: 1 + created_by_id: 1 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 diff --git a/pkg/db/helpers.go b/pkg/db/helpers.go index 915acba5f..77aeb5947 100644 --- a/pkg/db/helpers.go +++ b/pkg/db/helpers.go @@ -52,25 +52,18 @@ func MultiFieldSearch(fields []string, search string) builder.Cond { // for non-ParadeDB queries and the id field for ParadeDB queries. func MultiFieldSearchWithTableAlias(fields []string, search, tableAlias string) builder.Cond { if Type() == schemas.POSTGRES && paradedbInstalled { - if len(fields) == 1 { - // Single field search - use optimized match function - return builder.Expr("id @@@ paradedb.match(?, ?)", fields[0], search) - } - // Multi-field search - use disjunction_max for optimal performance - fieldMatches := make([]string, len(fields)) - args := make([]interface{}, len(fields)*2) + conditions := make([]builder.Cond, len(fields)) for i, field := range fields { - fieldMatches[i] = "paradedb.match(?, ?)" - args[i*2] = field - args[i*2+1] = search + fieldName := field + if tableAlias != "" { + fieldName = tableAlias + "." + field + } + conditions[i] = builder.Expr(fieldName+" ||| ?::pdb.fuzzy(1, t)", search) } - - idField := "`id`" - if tableAlias != "" { - idField = "`" + tableAlias + "`.`id`" + if len(conditions) == 1 { + return conditions[0] } - - return builder.Expr(idField+" @@@ paradedb.disjunction_max(ARRAY["+strings.Join(fieldMatches, ", ")+"])", args...) + return builder.Or(conditions...) } // For non-PostgreSQL databases, use ILIKE on all fields diff --git a/pkg/db/helpers_test.go b/pkg/db/helpers_test.go index 0890a673e..222e040f8 100644 --- a/pkg/db/helpers_test.go +++ b/pkg/db/helpers_test.go @@ -21,48 +21,101 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "xorm.io/builder" ) -func TestMultiFieldSearchLogic(t *testing.T) { - // Test the logic without requiring database initialization - fields := []string{"title", "description"} - search := "test" +// isParadeDB returns true when the test engine is running with ParadeDB. +// It is safe to call even when no database engine is initialized (x == nil), +// in which case it returns false. +func isParadeDB() bool { + return x != nil && ParadeDBAvailable() +} - // Test with ParadeDB enabled - originalParadeDB := paradedbInstalled - paradedbInstalled = true - defer func() { paradedbInstalled = originalParadeDB }() - - // We'll test the logic by checking if the right type of condition is created - // without relying on the Type() function that requires DB initialization - - // Create conditions manually for each database type - conditions := make([]builder.Cond, len(fields)) - for i, field := range fields { - conditions[i] = &builder.Like{field, "%" + search + "%"} - } - fallbackCond := builder.Or(conditions...) - - // Test ParadeDB query string generation - fieldQueries := make([]string, len(fields)) - for i, field := range fields { - fieldQueries[i] = field + ":" + search - } - expectedParadeDBQuery := "title:test OR description:test" - actualQuery := fieldQueries[0] + " OR " + fieldQueries[1] - - if actualQuery != expectedParadeDBQuery { - t.Errorf("Expected ParadeDB query '%s', got '%s'", expectedParadeDBQuery, actualQuery) +func TestMultiFieldSearchSingleField(t *testing.T) { + if x == nil { + t.Skip("requires initialized database engine") } - // Test that fallback condition is created correctly - if fallbackCond == nil { - t.Fatal("Expected non-nil fallback condition") + cond := MultiFieldSearchWithTableAlias([]string{"title"}, "landing", "") + + w := builder.NewWriter() + err := cond.WriteTo(w) + require.NoError(t, err) + + if isParadeDB() { + assert.Equal(t, "title ||| ?::pdb.fuzzy(1, t)", w.String()) + assert.Equal(t, []interface{}{"landing"}, w.Args()) + } else { + assert.Contains(t, w.String(), "title") + assert.Contains(t, w.String(), "LIKE") + assert.Equal(t, []interface{}{"%landing%"}, w.Args()) + } +} + +func TestMultiFieldSearchMultiField(t *testing.T) { + if x == nil { + t.Skip("requires initialized database engine") } - t.Logf("ParadeDB query would be: %s", expectedParadeDBQuery) - t.Logf("Fallback condition created successfully") + cond := MultiFieldSearchWithTableAlias([]string{"title", "description"}, "landing", "") + + w := builder.NewWriter() + err := cond.WriteTo(w) + require.NoError(t, err) + + if isParadeDB() { + assert.Equal(t, "(title ||| ?::pdb.fuzzy(1, t)) OR (description ||| ?::pdb.fuzzy(1, t))", w.String()) + assert.Equal(t, []interface{}{"landing", "landing"}, w.Args()) + } else { + assert.Contains(t, w.String(), "title") + assert.Contains(t, w.String(), "description") + assert.Contains(t, w.String(), "LIKE") + assert.Equal(t, []interface{}{"%landing%", "%landing%"}, w.Args()) + } +} + +func TestMultiFieldSearchWithTableAlias(t *testing.T) { + if x == nil { + t.Skip("requires initialized database engine") + } + + cond := MultiFieldSearchWithTableAlias([]string{"title"}, "test", "tasks") + + w := builder.NewWriter() + err := cond.WriteTo(w) + require.NoError(t, err) + + if isParadeDB() { + assert.Equal(t, "tasks.title ||| ?::pdb.fuzzy(1, t)", w.String()) + assert.Equal(t, []interface{}{"test"}, w.Args()) + } else { + assert.Contains(t, w.String(), "tasks.title") + assert.Contains(t, w.String(), "LIKE") + assert.Equal(t, []interface{}{"%test%"}, w.Args()) + } +} + +func TestMultiFieldSearchMultiFieldWithTableAlias(t *testing.T) { + if x == nil { + t.Skip("requires initialized database engine") + } + + cond := MultiFieldSearchWithTableAlias([]string{"title", "description"}, "test", "tasks") + + w := builder.NewWriter() + err := cond.WriteTo(w) + require.NoError(t, err) + + if isParadeDB() { + assert.Equal(t, "(tasks.title ||| ?::pdb.fuzzy(1, t)) OR (tasks.description ||| ?::pdb.fuzzy(1, t))", w.String()) + assert.Equal(t, []interface{}{"test", "test"}, w.Args()) + } else { + assert.Contains(t, w.String(), "tasks.title") + assert.Contains(t, w.String(), "tasks.description") + assert.Contains(t, w.String(), "LIKE") + assert.Equal(t, []interface{}{"%test%", "%test%"}, w.Args()) + } } func TestIsMySQLDuplicateEntryError(t *testing.T) { diff --git a/pkg/e2etests/integrations.go b/pkg/e2etests/integrations.go new file mode 100644 index 000000000..cb1ce6371 --- /dev/null +++ b/pkg/e2etests/integrations.go @@ -0,0 +1,147 @@ +// 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 e2etests + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "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/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +var registerListenersOnce sync.Once + +// Test users matching the fixture data in pkg/db/fixtures/users.yml +var ( + testuser1 = user.User{ + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user1@example.com", + Issuer: "local", + } +) + +// setupE2ETestEnv initializes the full application environment with real events. +// Unlike setupTestEnv in pkg/webtests/, this does NOT call events.Fake(), +// so events are dispatched through the real Watermill router to registered listeners. +func setupE2ETestEnv(ctx context.Context) (e *echo.Echo, err error) { + config.InitDefaultConfig() + config.ServicePublicURL.Set("https://localhost") + config.WebhooksEnabled.Set(true) + config.WebhooksAllowNonRoutableIPs.Set(true) + + log.InitLogger() + + files.InitTests() + user.InitTests() + models.SetupTests() //nolint:contextcheck + keyvalue.InitStorage() + + err = db.LoadFixtures() + if err != nil { + return + } + + // Register all listeners (including webhook listener) before starting the router. + // This must happen before InitEventsForTesting because the router wires up + // all listeners that were registered via events.RegisterListener(). + // Use sync.Once because RegisterListeners appends to the global registry + // and calling it multiple times would stack duplicate handlers. + registerListenersOnce.Do(models.RegisterListeners) + + // Start the real watermill event system. InitEventsForTesting initializes + // pubsub and starts the router in a background goroutine, returning a + // channel that closes once the router is ready. + ready, err := events.InitEventsForTesting(ctx) + if err != nil { + return + } + + // user.InitTests() calls events.Fake() which sets isUnderTest=true and + // prevents real event dispatch. Undo that now that pubsub is initialized. + events.Unfake() + + // Wait for the router to be ready before proceeding. + <-ready + + e = routes.NewEcho() //nolint:contextcheck + routes.RegisterRoutes(e) //nolint:contextcheck + return +} + +// createRequest builds an httptest request and echo context, mirroring webtests.createRequest +func createRequest(e *echo.Echo, method string, payload string, queryParam url.Values, urlParams map[string]string) (c *echo.Context, rec *httptest.ResponseRecorder) { + req := httptest.NewRequest(method, "/", strings.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.URL.RawQuery = queryParam.Encode() + rec = httptest.NewRecorder() + + c = e.NewContext(req, rec) + if len(urlParams) > 0 { + pathValues := make(echo.PathValues, 0, len(urlParams)) + for name, value := range urlParams { + pathValues = append(pathValues, echo.PathValue{Name: name, Value: value}) + } + c.SetPathValues(pathValues) + } + return +} + +// addUserTokenToContext creates a JWT for the user and sets it on the echo context +func addUserTokenToContext(t *testing.T, u *user.User, c *echo.Context) { + token, err := auth.NewUserJWTAuthtoken(u, "test-session-id") + require.NoError(t, err) + tken, err := jwt.Parse(token, func(_ *jwt.Token) (interface{}, error) { + return []byte(config.ServiceJWTSecret.GetString()), nil + }) + require.NoError(t, err) + c.Set("user", tken) +} + +// testUpdateWithUser performs a POST (update) request as the given user +func testUpdateWithUser(e *echo.Echo, t *testing.T, u *user.User, urlParams map[string]string, payload string) (rec *httptest.ResponseRecorder, err error) { + c, rec := createRequest(e, http.MethodPost, payload, nil, urlParams) + addUserTokenToContext(t, u, c) + + hndl := handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.Task{} + }, + } + err = hndl.UpdateWeb(c) + return +} diff --git a/tools.go b/pkg/e2etests/main_test.go similarity index 78% rename from tools.go rename to pkg/e2etests/main_test.go index 91bc0e331..83641694a 100644 --- a/tools.go +++ b/pkg/e2etests/main_test.go @@ -14,16 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//go:build tools - -package tools - -// This file is needed for go mod to recognize the tools we use. +package e2etests import ( - _ "github.com/cweill/gotests" - _ "github.com/swaggo/swag/cmd/swag" - _ "src.techknowlogick.com/xgo" - - _ "github.com/magefile/mage" + "flag" + "os" + "testing" ) + +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + println("-short requested, skipping e2e tests") + return + } + os.Exit(m.Run()) +} diff --git a/pkg/e2etests/user_webhook_test.go b/pkg/e2etests/user_webhook_test.go new file mode 100644 index 000000000..6cad410f0 --- /dev/null +++ b/pkg/e2etests/user_webhook_test.go @@ -0,0 +1,430 @@ +// 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 e2etests + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// webhookCapture is a test helper that starts an HTTP server to capture webhook payloads. +type webhookCapture struct { + server *httptest.Server + payloads chan webhookDelivery + mu sync.Mutex + received []webhookDelivery +} + +type webhookDelivery struct { + Body []byte + Headers http.Header +} + +func newWebhookCapture() *webhookCapture { + wc := &webhookCapture{ + payloads: make(chan webhookDelivery, 10), + } + wc.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + delivery := webhookDelivery{ + Body: body, + Headers: r.Header.Clone(), + } + wc.mu.Lock() + wc.received = append(wc.received, delivery) + wc.mu.Unlock() + select { + case wc.payloads <- delivery: + default: + } + w.WriteHeader(http.StatusOK) + })) + return wc +} + +func (wc *webhookCapture) URL() string { + return wc.server.URL +} + +func (wc *webhookCapture) Close() { + wc.server.Close() +} + +// waitForPayload waits for a webhook payload to arrive within 10 seconds. +func (wc *webhookCapture) waitForPayload(t *testing.T) webhookDelivery { + t.Helper() + select { + case d := <-wc.payloads: + return d + case <-time.After(10 * time.Second): + t.Fatal("Webhook payload not received within timeout") + return webhookDelivery{} // unreachable + } +} + +// assertNoPayload asserts that no webhook payload arrives within the given duration. +func (wc *webhookCapture) assertNoPayload(t *testing.T, wait time.Duration) { + t.Helper() + select { + case d := <-wc.payloads: + t.Fatalf("Expected no webhook payload but received one: %s", string(d.Body)) + case <-time.After(wait): + // success — nothing arrived + } +} + +// insertUserWebhook creates a user-level webhook (no project_id) in the database. +func insertUserWebhook(t *testing.T, userID int64, targetURL string, evts []string) { + t.Helper() + s := db.NewSession() + defer s.Close() + _, err := s.Insert(&models.Webhook{ + TargetURL: targetURL, + Events: evts, + UserID: userID, + CreatedByID: userID, + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) +} + +// insertUserWebhookWithSecret creates a user-level webhook with an HMAC secret. +func insertUserWebhookWithSecret(t *testing.T, userID int64, targetURL string, evts []string, secret string) { + t.Helper() + s := db.NewSession() + defer s.Close() + _, err := s.Insert(&models.Webhook{ + TargetURL: targetURL, + Events: evts, + UserID: userID, + CreatedByID: userID, + Secret: secret, + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) +} + +// resetWebhooks reloads all fixtures to start each test from a clean database state. +func resetWebhooks(t *testing.T) { + t.Helper() + require.NoError(t, db.LoadFixtures()) +} + +// parseWebhookPayload parses a webhook delivery body into a structured map. +func parseWebhookPayload(t *testing.T, d webhookDelivery) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(d.Body, &payload)) + return payload +} + +func TestUserWebhookTaskOverdueE2E(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.overdue"}) + + // Dispatch a TaskOverdueEvent directly — this simulates the overdue cron job + err = events.Dispatch(&models.TaskOverdueEvent{ + Task: &models.Task{ + ID: 1, + Title: "Overdue task", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + delivery := capture.waitForPayload(t) + payload := parseWebhookPayload(t, delivery) + + assert.Equal(t, "task.overdue", payload["event_name"]) + data, ok := payload["data"].(map[string]interface{}) + require.True(t, ok, "payload.data should be a map") + task, ok := data["task"].(map[string]interface{}) + require.True(t, ok, "payload.data.task should be a map") + assert.Equal(t, "Overdue task", task["title"]) +} + +func TestUserWebhookTaskReminderFiredE2E(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.reminder.fired"}) + + // Dispatch a TaskReminderFiredEvent directly — simulates the reminder cron job + err = events.Dispatch(&models.TaskReminderFiredEvent{ + Task: &models.Task{ + ID: 1, + Title: "Reminder task", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + delivery := capture.waitForPayload(t) + payload := parseWebhookPayload(t, delivery) + + assert.Equal(t, "task.reminder.fired", payload["event_name"]) + data, ok := payload["data"].(map[string]interface{}) + require.True(t, ok, "payload.data should be a map") + task, ok := data["task"].(map[string]interface{}) + require.True(t, ok, "payload.data.task should be a map") + assert.Equal(t, "Reminder task", task["title"]) +} + +func TestUserWebhookDoesNotFireForOtherUsers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + // Create a user-level webhook for user 2 + insertUserWebhook(t, 2, capture.URL(), []string{"task.overdue"}) + + // Dispatch an overdue event for user 1 — user 2's webhook should NOT fire + err = events.Dispatch(&models.TaskOverdueEvent{ + Task: &models.Task{ + ID: 1, + Title: "Overdue for user 1", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + capture.assertNoPayload(t, 3*time.Second) +} + +func TestUserWebhookAndProjectWebhookBothFire(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + userCapture := newWebhookCapture() + defer userCapture.Close() + projectCapture := newWebhookCapture() + defer projectCapture.Close() + + resetWebhooks(t) + + // User-level webhook for user 1 listening to task.overdue + insertUserWebhook(t, testuser1.ID, userCapture.URL(), []string{"task.overdue"}) + + // Project-level webhook for project 1 listening to task.overdue + s := db.NewSession() + _, err = s.Insert(&models.Webhook{ + TargetURL: projectCapture.URL(), + Events: []string{"task.overdue"}, + ProjectID: 1, + CreatedByID: 1, + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) + s.Close() + + // Dispatch overdue event — both project-level and user-level webhooks should fire + err = events.Dispatch(&models.TaskOverdueEvent{ + Task: &models.Task{ + ID: 1, + Title: "Both webhooks overdue", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + // Both should receive the payload + projectDelivery := projectCapture.waitForPayload(t) + projectPayload := parseWebhookPayload(t, projectDelivery) + assert.Equal(t, "task.overdue", projectPayload["event_name"]) + + userDelivery := userCapture.waitForPayload(t) + userPayload := parseWebhookPayload(t, userDelivery) + assert.Equal(t, "task.overdue", userPayload["event_name"]) +} + +func TestUserWebhookHMACSigning(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + secret := "test-hmac-secret-for-user-webhook" + insertUserWebhookWithSecret(t, testuser1.ID, capture.URL(), []string{"task.overdue"}, secret) + + err = events.Dispatch(&models.TaskOverdueEvent{ + Task: &models.Task{ + ID: 1, + Title: "HMAC overdue", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + delivery := capture.waitForPayload(t) + + // Verify the HMAC signature header is present and correct + signature := delivery.Headers.Get("X-Vikunja-Signature") + require.NotEmpty(t, signature, "X-Vikunja-Signature header should be set") + + mac := hmac.New(sha256.New, []byte(secret)) + _, err = mac.Write(delivery.Body) + require.NoError(t, err) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + assert.Equal(t, expectedSig, signature, "HMAC signature should match") +} + +func TestUserWebhookOnlyMatchesSubscribedEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + // Subscribe only to task.reminder.fired — task.overdue should NOT trigger it + insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.reminder.fired"}) + + err = events.Dispatch(&models.TaskOverdueEvent{ + Task: &models.Task{ + ID: 1, + Title: "Wrong event", + ProjectID: 1, + }, + User: &testuser1, + Project: &models.Project{ID: 1, Title: "Test Project"}, + }) + require.NoError(t, err) + + capture.assertNoPayload(t, 3*time.Second) +} + +func TestUserWebhookDoesNotFireForProjectEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + e, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + // User-level webhook subscribed to task.updated (a non-user-directed event) + insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.updated"}) + + // Update task via the web handler — user-level webhooks should NOT fire + // for project-scoped events like task.updated + _, err = testUpdateWithUser(e, t, &testuser1, + map[string]string{"projecttask": "1"}, + `{"title":"Should not trigger user webhook"}`, + ) + require.NoError(t, err) + + capture.assertNoPayload(t, 3*time.Second) +} + +func TestUserWebhookTasksOverdueBatchE2E(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + capture := newWebhookCapture() + defer capture.Close() + + resetWebhooks(t) + insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"tasks.overdue"}) + + // Dispatch a batch TasksOverdueEvent + err = events.Dispatch(&models.TasksOverdueEvent{ + Tasks: []*models.Task{ + {ID: 1, Title: "Overdue 1", ProjectID: 1}, + {ID: 2, Title: "Overdue 2", ProjectID: 1}, + }, + User: &user.User{ID: testuser1.ID, Username: testuser1.Username}, + Projects: map[int64]*models.Project{ + 1: {ID: 1, Title: "Test Project"}, + }, + }) + require.NoError(t, err) + + delivery := capture.waitForPayload(t) + payload := parseWebhookPayload(t, delivery) + + assert.Equal(t, "tasks.overdue", payload["event_name"]) + data, ok := payload["data"].(map[string]interface{}) + require.True(t, ok, "payload.data should be a map") + tasks, ok := data["tasks"].([]interface{}) + require.True(t, ok, "payload.data.tasks should be an array") + assert.Len(t, tasks, 2) +} diff --git a/pkg/e2etests/webhook_test.go b/pkg/e2etests/webhook_test.go new file mode 100644 index 000000000..c56b14eef --- /dev/null +++ b/pkg/e2etests/webhook_test.go @@ -0,0 +1,96 @@ +// 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 e2etests + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskUpdateWebhookE2E(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + e, err := setupE2ETestEnv(ctx) + require.NoError(t, err) + + // Start a test HTTP server to capture webhook payloads. + // Use a non-blocking send so retries or duplicate deliveries don't hang. + webhookReceived := make(chan []byte, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + select { + case webhookReceived <- body: + default: + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + // Reload fixtures to start from a clean state, then insert + // a fresh webhook for project 1 listening to "task.updated". + require.NoError(t, db.LoadFixtures()) + s := db.NewSession() + defer s.Close() + _, err = s.Insert(&models.Webhook{ + TargetURL: ts.URL, + Events: []string{"task.updated"}, + ProjectID: 1, + CreatedByID: 1, + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) + + // Update task 1 via the web handler — this triggers the full pipeline: + // UpdateWeb → Task.Update() → DispatchOnCommit → s.Commit() → + // DispatchPending → Dispatch → watermill → WebhookListener.Handle → + // HTTP POST to ts.URL + rec, err := testUpdateWithUser(e, t, &testuser1, + map[string]string{"projecttask": "1"}, + `{"title":"E2E webhook test"}`, + ) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"E2E webhook test"`) + + // Wait for the webhook payload to arrive via the real async pipeline + select { + case body := <-webhookReceived: + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "task.updated", payload["event_name"]) + + data, ok := payload["data"].(map[string]interface{}) + require.True(t, ok, "payload.data should be a map") + task, ok := data["task"].(map[string]interface{}) + require.True(t, ok, "payload.data.task should be a map") + assert.Equal(t, "E2E webhook test", task["title"]) + + case <-time.After(10 * time.Second): + t.Fatal("Webhook payload not received within 10s timeout") + } +} diff --git a/pkg/events/events.go b/pkg/events/events.go index 57b02dc37..30c26ea99 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -139,6 +139,66 @@ func InitEvents() (err error) { return router.Run(context.Background()) } +// InitEventsForTesting sets up the event system like InitEvents but accepts a +// context so the watermill router can be shut down cleanly in tests. +// It starts the router in a background goroutine and returns a channel that is +// closed once the router is ready to accept messages. +func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) { + logger := log.NewWatermillLogger(config.LogEnabled.GetBool(), config.LogEvents.GetString(), config.LogEventsLevel.GetString(), config.LogFormat.GetString()) + + router, err := message.NewRouter( + message.RouterConfig{}, + logger, + ) + if err != nil { + return nil, err + } + + pubsub = gochannel.NewGoChannel( + gochannel.Config{ + OutputChannelBuffer: 1024, + }, + logger, + ) + + // No prometheus metrics in tests — avoids duplicate registration panics + // No poison queue — keep test output clean, let errors surface directly + + handlerTracker := func(h message.HandlerFunc) message.HandlerFunc { + return func(msg *message.Message) ([]*message.Message, error) { + activeHandlers.Add(1) + defer activeHandlers.Done() + return h(msg) + } + } + + router.AddMiddleware( + handlerTracker, + middleware.Retry{ + MaxRetries: 3, + InitialInterval: time.Millisecond * 50, + MaxInterval: time.Second, + Multiplier: 2, + Logger: logger, + }.Middleware, + middleware.Recoverer, + ) + + for topic, funcs := range listeners { + for _, handler := range funcs { + router.AddConsumerHandler(topic+"."+handler.Name(), topic, pubsub, handler.Handle) + } + } + + ready := router.Running() + go func() { + if err := router.Run(ctx); err != nil { + log.Errorf("Event system error: %s", err) + } + }() + return ready, nil +} + // Dispatch dispatches an event func Dispatch(event Event) error { if isUnderTest { @@ -146,6 +206,10 @@ func Dispatch(event Event) error { return nil } + if pubsub == nil { + return fmt.Errorf("event system not initialized: call InitEvents or InitEventsForTesting before dispatching") + } + content, err := json.Marshal(event) if err != nil { return err diff --git a/pkg/events/testing.go b/pkg/events/testing.go index b422e7c20..2c969f057 100644 --- a/pkg/events/testing.go +++ b/pkg/events/testing.go @@ -39,6 +39,14 @@ func Fake() { dispatchedTestEvents = nil } +// Unfake disables "test mode" so that events are dispatched through the real +// watermill pub/sub instead of being recorded. Call this after Fake() when you +// need the full event pipeline (e.g. in end-to-end tests). +func Unfake() { + isUnderTest = false + dispatchedTestEvents = nil +} + // AssertDispatched asserts an event has been dispatched. func AssertDispatched(t *testing.T, event Event) { var found bool diff --git a/pkg/files/filehandling.go b/pkg/files/filehandling.go index 6cd81d568..474c8a576 100644 --- a/pkg/files/filehandling.go +++ b/pkg/files/filehandling.go @@ -22,8 +22,6 @@ import ( "errors" "fmt" "os" - "path/filepath" - "strings" "testing" "time" @@ -37,30 +35,14 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" - aferos3 "github.com/fclairamb/afero-s3" - "github.com/spf13/afero" "github.com/stretchr/testify/require" ) // This file handles storing and retrieving a file for different backends -var fs afero.Fs -var afs *afero.Afero - -// S3 client and bucket for direct uploads with Content-Length -type s3PutObjectClient interface { - PutObject(ctx context.Context, input *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) -} - -var s3Client s3PutObjectClient -var s3Bucket string +var storage FileStorage func setDefaultLocalConfig() { - if !strings.HasPrefix(config.FilesBasePath.GetString(), "/") { - config.FilesBasePath.Set(filepath.Join( - config.ServiceRootpath.GetString(), - config.FilesBasePath.GetString(), - )) - } + config.FilesBasePath.Set(config.ResolvePath(config.FilesBasePath.GetString())) } // initS3FileHandler initializes the S3 file backend @@ -103,23 +85,15 @@ func initS3FileHandler() error { } }) - // Initialize S3 filesystem using afero-s3 - fs = aferos3.NewFsFromClient(bucket, client) - afs = &afero.Afero{Fs: fs} - - // Store S3 client and bucket for direct uploads with Content-Length - s3Client = client - s3Bucket = bucket + storage = newS3Storage(bucket, config.FilesBasePath.GetString(), client) return nil } // initLocalFileHandler initializes the local filesystem backend func initLocalFileHandler() { - fs = afero.NewOsFs() - afs = &afero.Afero{Fs: fs} - s3Client = nil setDefaultLocalConfig() + storage = newLocalStorage(config.FilesBasePath.GetString()) } // InitFileHandler creates a new file handler for the file backend we want to use @@ -146,9 +120,8 @@ func InitFileHandler() error { // InitTestFileHandler initializes a new memory file system for testing func InitTestFileHandler() { - fs = afero.NewMemMapFs() - afs = &afero.Afero{Fs: fs} setDefaultLocalConfig() + storage = newMemStorage() } func initFixtures(t *testing.T) { @@ -163,7 +136,7 @@ func initFixtures(t *testing.T) { // InitTestFileFixtures initializes file fixtures func InitTestFileFixtures(t *testing.T) { testfile := &File{ID: 1} - err := afero.WriteFile(afs, testfile.getAbsoluteFilePath(), []byte("testfile1"), 0644) + err := storage.Write(testfile.fileID(), bytes.NewReader([]byte("testfile1")), 9) require.NoError(t, err) } @@ -190,9 +163,9 @@ func InitTests() { keyvalue.InitStorage() } -// FileStat stats a file. This is an exported function to be able to test this from outide of the package +// FileStat stats a file. This is an exported function to be able to test this from outside of the package func FileStat(file *File) (os.FileInfo, error) { - return afs.Stat(file.getAbsoluteFilePath()) + return storage.Stat(file.fileID()) } // ValidateFileStorage checks that the configured file storage is writable @@ -207,36 +180,31 @@ func ValidateFileStorage() error { // For local filesystem, ensure the base directory exists if config.FilesType.GetString() == "local" { - // Check if directory exists - info, err := afs.Stat(basePath) + info, err := os.Stat(basePath) if err != nil { if !errors.Is(err, os.ErrNotExist) { - // Error other than "file doesn't exist" return fmt.Errorf("failed to access file storage directory at %s: %w%s", basePath, err, diag) } - // Directory doesn't exist, try to create it - err = afs.MkdirAll(basePath, 0755) + err = os.MkdirAll(basePath, 0755) if err != nil { return fmt.Errorf("failed to create file storage directory at %s: %w%s", basePath, err, diag) } } else if !info.IsDir() { - // Path exists but is not a directory return fmt.Errorf("file storage path exists but is not a directory: %s", basePath) } } filename := fmt.Sprintf(".vikunja-check-%d", time.Now().UnixNano()) - path := filepath.Join(basePath, filename) - err := writeToStorage(path, bytes.NewReader([]byte{}), 0) + err := storage.Write(filename, bytes.NewReader([]byte{}), 0) if err != nil { - return fmt.Errorf("failed to create test file at %s: %w%s", path, err, diag) + return fmt.Errorf("failed to create test file: %w%s", err, diag) } - err = afs.Remove(path) + err = storage.Remove(filename) if err != nil { - return fmt.Errorf("failed to remove test file at %s: %w", path, err) + return fmt.Errorf("failed to remove test file: %w", err) } return nil diff --git a/pkg/files/files.go b/pkg/files/files.go index 3b37c559e..3c702c233 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -17,13 +17,10 @@ package files import ( - "context" "errors" "fmt" "io" - "math" "os" - "path/filepath" "strconv" "time" @@ -34,11 +31,8 @@ import ( "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/web" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/c2h5oh/datasize" "github.com/gabriel-vasile/mimetype" - "github.com/spf13/afero" "xorm.io/xorm" ) @@ -52,7 +46,7 @@ type File struct { Created time.Time `xorm:"created" json:"created"` CreatedByID int64 `xorm:"bigint not null" json:"-"` - File afero.File `xorm:"-" json:"-"` + File io.ReadCloser `xorm:"-" json:"-"` // This ReadCloser is only used for migration purposes. Use with care! // There is currentlc no better way of doing this. FileContent []byte `xorm:"-" json:"-"` @@ -63,16 +57,13 @@ func (*File) TableName() string { return "files" } -func (f *File) getAbsoluteFilePath() string { - return filepath.Join( - config.FilesBasePath.GetString(), - strconv.FormatInt(f.ID, 10), - ) +func (f *File) fileID() string { + return strconv.FormatInt(f.ID, 10) } // LoadFileByID returns a file by its ID func (f *File) LoadFileByID() (err error) { - f.File, err = afs.Open(f.getAbsoluteFilePath()) + f.File, err = storage.Open(f.fileID()) return } @@ -157,7 +148,7 @@ func (f *File) Delete(s *xorm.Session) (err error) { return ErrFileDoesNotExist{FileID: f.ID} } - err = afs.Remove(f.getAbsoluteFilePath()) + err = storage.Remove(f.fileID()) if err != nil { var perr *os.PathError if errors.As(err, &perr) { @@ -172,55 +163,11 @@ func (f *File) Delete(s *xorm.Session) (err error) { return keyvalue.DecrBy(metrics.FilesCountKey, 1) } -// writeToStorage writes content to the given path, handling both local and S3 backends. -func writeToStorage(path string, content io.ReadSeeker, size uint64) error { - if s3Client == nil { - if _, err := content.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to seek to start of content: %w", err) - } - return afs.WriteReader(path, content) - } - - contentLength, err := contentLengthFromReadSeeker(content, size) - if err != nil { - return fmt.Errorf("failed to determine S3 upload content length: %w", err) - } - - if _, err = content.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to seek to start before S3 upload: %w", err) - } - - _, err = s3Client.PutObject(context.Background(), &s3.PutObjectInput{ - Bucket: aws.String(s3Bucket), - Key: aws.String(path), - Body: content, - ContentLength: aws.Int64(contentLength), - }) - if err != nil { - return fmt.Errorf("failed to upload file to S3: %w", err) - } - return nil -} - // Save saves a file to storage func (f *File) Save(fcontent io.ReadSeeker) error { - err := writeToStorage(f.getAbsoluteFilePath(), fcontent, f.Size) + err := storage.Write(f.fileID(), fcontent, f.Size) if err != nil { return fmt.Errorf("failed to save file: %w", err) } return keyvalue.IncrBy(metrics.FilesCountKey, 1) } - -// contentLengthFromReadSeeker determines the content length by seeking to the end. -func contentLengthFromReadSeeker(seeker io.ReadSeeker, expectedSize uint64) (int64, error) { - endOffset, err := seeker.Seek(0, io.SeekEnd) - if err != nil { - return 0, err - } - - if expectedSize > 0 && expectedSize <= uint64(math.MaxInt64) && endOffset != int64(expectedSize) { - log.Warningf("File size mismatch for S3 upload: expected %d bytes but reader reports %d bytes", expectedSize, endOffset) - } - - return endOffset, nil -} diff --git a/pkg/files/files_test.go b/pkg/files/files_test.go index cdaad2f5d..d8dc9494c 100644 --- a/pkg/files/files_test.go +++ b/pkg/files/files_test.go @@ -20,6 +20,7 @@ import ( "bytes" "image" "image/png" + "io" "os" "testing" @@ -130,6 +131,30 @@ func TestFile_LoadFileByID(t *testing.T) { }) } +func TestFileSave_UsesStorage(t *testing.T) { + originalStorage := storage + t.Cleanup(func() { + storage = originalStorage + }) + + mem := newMemStorage() + storage = mem + + content := []byte("test-content") + file := &File{ID: 123, Size: uint64(len(content))} + + err := file.Save(bytes.NewReader(content)) + require.NoError(t, err) + + rc, err := mem.Open(file.fileID()) + require.NoError(t, err) + defer rc.Close() + + written, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, content, written) +} + func TestFile_LoadFileMetaByID(t *testing.T) { t.Run("Normal", func(t *testing.T) { initFixtures(t) diff --git a/pkg/files/repair.go b/pkg/files/repair.go index a1d8f4fba..30abd826a 100644 --- a/pkg/files/repair.go +++ b/pkg/files/repair.go @@ -54,7 +54,7 @@ func RepairFileMimeTypes(s *xorm.Session, dryRun bool) (*RepairMimeTypesResult, bar := progressbar.Default(int64(len(files)), "Detecting MIME types") for _, f := range files { - file, err := afs.Open(f.getAbsoluteFilePath()) + file, err := storage.Open(f.fileID()) if err != nil { msg := fmt.Sprintf("file %d: failed to open: %s", f.ID, err) log.Errorf("file %d: failed to open: %s", f.ID, err) diff --git a/pkg/files/s3_test.go b/pkg/files/s3_test.go index 6417156c1..f0d529ece 100644 --- a/pkg/files/s3_test.go +++ b/pkg/files/s3_test.go @@ -18,15 +18,12 @@ package files import ( "bytes" - "context" - "errors" "io" "os" "testing" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" - "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -49,7 +46,7 @@ func TestFileStorageIntegration(t *testing.T) { t.Run("Initialize file handler with s3", func(t *testing.T) { err := InitFileHandler() require.NoError(t, err, "Failed to initialize file handler with type: s3") - assert.NotNil(t, afs, "File system should be initialized") + assert.NotNil(t, storage, "File storage should be initialized") }) t.Run("Create and retrieve file with s3", func(t *testing.T) { @@ -304,88 +301,6 @@ func TestInitFileHandler_LocalFilesystem(t *testing.T) { err := InitFileHandler() require.NoError(t, err) - // Verify that afs is initialized - assert.NotNil(t, afs) -} - -type fakeS3PutObjectClient struct { - lastInput *s3.PutObjectInput - err error -} - -func (f *fakeS3PutObjectClient) PutObject(_ context.Context, input *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - f.lastInput = input - if f.err != nil { - return nil, f.err - } - return &s3.PutObjectOutput{}, nil -} - -func TestFileSave_S3_UsesSeekableReader(t *testing.T) { - originalClient := s3Client - originalBucket := s3Bucket - t.Cleanup(func() { - s3Client = originalClient - s3Bucket = originalBucket - }) - - client := &fakeS3PutObjectClient{} - s3Client = client - s3Bucket = "test-bucket" - - content := []byte("seekable-content") - file := &File{ID: 123, Size: uint64(len(content))} - - err := file.Save(bytes.NewReader(content)) - require.NoError(t, err) - - require.NotNil(t, client.lastInput) - assert.Equal(t, "test-bucket", *client.lastInput.Bucket) - assert.Equal(t, file.getAbsoluteFilePath(), *client.lastInput.Key) - require.NotNil(t, client.lastInput.ContentLength) - assert.Equal(t, int64(len(content)), *client.lastInput.ContentLength) - assert.IsType(t, &bytes.Reader{}, client.lastInput.Body) -} - -func TestFileSave_S3_ReturnsErrorOnPutObjectFailure(t *testing.T) { - originalClient := s3Client - originalBucket := s3Bucket - t.Cleanup(func() { - s3Client = originalClient - s3Bucket = originalBucket - }) - - client := &fakeS3PutObjectClient{err: errors.New("boom")} - s3Client = client - s3Bucket = "test-bucket" - - content := []byte("test-content") - file := &File{ID: 789, Size: uint64(len(content))} - - err := file.Save(bytes.NewReader(content)) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to upload file to S3") -} - -func TestFileSave_S3_UsesActualReaderSizeOnMismatch(t *testing.T) { - originalClient := s3Client - originalBucket := s3Bucket - t.Cleanup(func() { - s3Client = originalClient - s3Bucket = originalBucket - }) - - client := &fakeS3PutObjectClient{} - s3Client = client - s3Bucket = "test-bucket" - - content := []byte("mismatch-content") - file := &File{ID: 999, Size: uint64(len(content) + 10)} - - err := file.Save(bytes.NewReader(content)) - require.NoError(t, err) - - require.NotNil(t, client.lastInput) - require.NotNil(t, client.lastInput.ContentLength) - assert.Equal(t, int64(len(content)), *client.lastInput.ContentLength) + // Verify that storage is initialized + assert.NotNil(t, storage) } diff --git a/pkg/files/storage.go b/pkg/files/storage.go new file mode 100644 index 000000000..24ad1a693 --- /dev/null +++ b/pkg/files/storage.go @@ -0,0 +1,31 @@ +// 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" + "os" +) + +// FileStorage abstracts file storage operations across local, S3, and in-memory backends. +type FileStorage interface { + Open(path string) (io.ReadCloser, error) + Write(path string, content io.ReadSeeker, size uint64) error + Stat(path string) (os.FileInfo, error) + Remove(path string) error + MkdirAll(path string, perm os.FileMode) error +} diff --git a/pkg/files/storage_local.go b/pkg/files/storage_local.go new file mode 100644 index 000000000..552f1e491 --- /dev/null +++ b/pkg/files/storage_local.go @@ -0,0 +1,68 @@ +// 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" + "os" + "path/filepath" +) + +// localStorage implements FileStorage using the OS filesystem. +// All paths are resolved relative to basePath. +type localStorage struct { + basePath string +} + +func newLocalStorage(basePath string) *localStorage { + return &localStorage{basePath: basePath} +} + +func (l *localStorage) path(name string) string { + return filepath.Join(l.basePath, name) +} + +func (l *localStorage) Open(name string) (io.ReadCloser, error) { + return os.Open(l.path(name)) +} + +func (l *localStorage) Write(name string, content io.ReadSeeker, _ uint64) error { + if _, err := content.Seek(0, io.SeekStart); err != nil { + return err + } + + f, err := os.Create(l.path(name)) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, content) + return err +} + +func (l *localStorage) Stat(name string) (os.FileInfo, error) { + return os.Stat(l.path(name)) +} + +func (l *localStorage) Remove(name string) error { + return os.Remove(l.path(name)) +} + +func (l *localStorage) MkdirAll(p string, perm os.FileMode) error { + return os.MkdirAll(l.path(p), perm) +} diff --git a/pkg/files/storage_local_test.go b/pkg/files/storage_local_test.go new file mode 100644 index 000000000..c627f5490 --- /dev/null +++ b/pkg/files/storage_local_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 files + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalStorage_WriteAndOpen(t *testing.T) { + dir := t.TempDir() + s := newLocalStorage(dir) + + content := []byte("hello local") + + err := s.Write("testfile", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + rc, err := s.Open("testfile") + require.NoError(t, err) + defer rc.Close() + + got, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, content, got) +} + +func TestLocalStorage_Stat(t *testing.T) { + dir := t.TempDir() + s := newLocalStorage(dir) + + content := []byte("stat me") + + err := s.Write("statfile", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + info, err := s.Stat("statfile") + require.NoError(t, err) + assert.Equal(t, int64(len(content)), info.Size()) +} + +func TestLocalStorage_Remove(t *testing.T) { + dir := t.TempDir() + s := newLocalStorage(dir) + + content := []byte("remove me") + + err := s.Write("removefile", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + err = s.Remove("removefile") + require.NoError(t, err) + + _, err = s.Stat("removefile") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) +} + +func TestLocalStorage_MkdirAll(t *testing.T) { + dir := t.TempDir() + s := newLocalStorage(dir) + + err := s.MkdirAll(filepath.Join("a", "b", "c"), 0755) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dir, "a", "b", "c")) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} diff --git a/pkg/files/storage_mem.go b/pkg/files/storage_mem.go new file mode 100644 index 000000000..0a53f8a58 --- /dev/null +++ b/pkg/files/storage_mem.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 files + +import ( + "bytes" + "io" + "os" + "path" + "sync" + "time" +) + +// memStorage is an in-memory FileStorage for tests. +type memStorage struct { + mu sync.RWMutex + files map[string][]byte +} + +func newMemStorage() *memStorage { + return &memStorage{files: make(map[string][]byte)} +} + +func (m *memStorage) Open(name string) (io.ReadCloser, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + data, ok := m.files[name] + if !ok { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} + } + return io.NopCloser(bytes.NewReader(data)), nil +} + +func (m *memStorage) Write(name string, content io.ReadSeeker, _ uint64) error { + if _, err := content.Seek(0, io.SeekStart); err != nil { + return err + } + data, err := io.ReadAll(content) + if err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + m.files[name] = data + return nil +} + +func (m *memStorage) Stat(name string) (os.FileInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + data, ok := m.files[name] + if !ok { + return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} + } + return &memFileInfo{ + name: path.Base(name), + size: int64(len(data)), + }, nil +} + +func (m *memStorage) Remove(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.files[name]; !ok { + return &os.PathError{Op: "remove", Path: name, Err: os.ErrNotExist} + } + delete(m.files, name) + return nil +} + +func (*memStorage) MkdirAll(string, os.FileMode) error { + return nil +} + +type memFileInfo struct { + name string + size int64 +} + +func (fi *memFileInfo) Name() string { return fi.name } +func (fi *memFileInfo) Size() int64 { return fi.size } +func (fi *memFileInfo) Mode() os.FileMode { return 0644 } +func (fi *memFileInfo) ModTime() time.Time { return time.Time{} } +func (fi *memFileInfo) IsDir() bool { return false } +func (fi *memFileInfo) Sys() interface{} { return nil } diff --git a/pkg/files/storage_mem_test.go b/pkg/files/storage_mem_test.go new file mode 100644 index 000000000..3abf8766d --- /dev/null +++ b/pkg/files/storage_mem_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 files + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMemStorage_WriteAndOpen(t *testing.T) { + s := newMemStorage() + content := []byte("hello world") + + err := s.Write("test/file.txt", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + rc, err := s.Open("test/file.txt") + require.NoError(t, err) + defer rc.Close() + + got, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, content, got) +} + +func TestMemStorage_Stat(t *testing.T) { + s := newMemStorage() + content := []byte("hello world") + + err := s.Write("test/file.txt", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + info, err := s.Stat("test/file.txt") + require.NoError(t, err) + assert.Equal(t, "file.txt", info.Name()) + assert.Equal(t, int64(len(content)), info.Size()) + assert.False(t, info.IsDir()) +} + +func TestMemStorage_StatNotFound(t *testing.T) { + s := newMemStorage() + + _, err := s.Stat("nonexistent") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) +} + +func TestMemStorage_Remove(t *testing.T) { + s := newMemStorage() + content := []byte("hello") + + err := s.Write("test/file.txt", bytes.NewReader(content), uint64(len(content))) + require.NoError(t, err) + + err = s.Remove("test/file.txt") + require.NoError(t, err) + + _, err = s.Open("test/file.txt") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) +} + +func TestMemStorage_OpenNotFound(t *testing.T) { + s := newMemStorage() + + _, err := s.Open("nonexistent") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) +} + +func TestMemStorage_RemoveNotFound(t *testing.T) { + s := newMemStorage() + + err := s.Remove("nonexistent") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) +} + +func TestMemStorage_MkdirAll(t *testing.T) { + s := newMemStorage() + // Should be a no-op, no error + err := s.MkdirAll("/some/path", 0755) + require.NoError(t, err) +} diff --git a/pkg/files/storage_s3.go b/pkg/files/storage_s3.go new file mode 100644 index 000000000..0ea674087 --- /dev/null +++ b/pkg/files/storage_s3.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 files + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + smithyhttp "github.com/aws/smithy-go/transport/http" + + "code.vikunja.io/api/pkg/log" +) + +// s3Storage implements FileStorage backed by S3. +// All paths are prefixed with basePath to form S3 object keys. +type s3Storage struct { + client *s3.Client + bucket string + basePath string +} + +func newS3Storage(bucket, basePath string, client *s3.Client) *s3Storage { + return &s3Storage{bucket: bucket, basePath: basePath, client: client} +} + +func (s *s3Storage) key(name string) string { + return path.Join(s.basePath, name) +} + +func (s *s3Storage) Open(name string) (io.ReadCloser, error) { + key := s.key(name) + out, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, s3ToPathError("open", name, err) + } + return out.Body, nil +} + +func (s *s3Storage) Write(name string, content io.ReadSeeker, size uint64) error { + contentLength, err := contentLengthFromReadSeeker(content, size) + if err != nil { + return fmt.Errorf("failed to determine S3 upload content length: %w", err) + } + + if _, err = content.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek to start before S3 upload: %w", err) + } + + _, err = s.client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s.key(name)), + Body: content, + ContentLength: aws.Int64(contentLength), + }) + if err != nil { + return fmt.Errorf("failed to upload file to S3: %w", err) + } + return nil +} + +func (s *s3Storage) Stat(name string) (os.FileInfo, error) { + key := s.key(name) + head, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, s3ToPathError("stat", name, err) + } + + var size int64 + if head.ContentLength != nil { + size = *head.ContentLength + } + var modTime time.Time + if head.LastModified != nil { + modTime = *head.LastModified + } + + return &s3FileInfo{ + name: path.Base(name), + size: size, + modTime: modTime, + }, nil +} + +func (s *s3Storage) Remove(name string) error { + // Check existence first for proper error on missing files + if _, err := s.Stat(name); err != nil { + return err + } + + _, err := s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s.key(name)), + }) + return err +} + +func (*s3Storage) MkdirAll(string, os.FileMode) error { + return nil // S3 has no directories +} + +// s3ToPathError converts S3 SDK errors into os-compatible path errors. +func s3ToPathError(op, name string, err error) error { + var respErr *smithyhttp.ResponseError + if errors.As(err, &respErr) && respErr.HTTPStatusCode() == 404 { + return &os.PathError{Op: op, Path: name, Err: os.ErrNotExist} + } + return &os.PathError{Op: op, Path: name, Err: err} +} + +// s3FileInfo implements os.FileInfo for S3 objects. +type s3FileInfo struct { + name string + size int64 + modTime time.Time +} + +func (fi *s3FileInfo) Name() string { return fi.name } +func (fi *s3FileInfo) Size() int64 { return fi.size } +func (fi *s3FileInfo) Mode() os.FileMode { return 0664 } +func (fi *s3FileInfo) ModTime() time.Time { return fi.modTime } +func (fi *s3FileInfo) IsDir() bool { return false } +func (fi *s3FileInfo) Sys() interface{} { return nil } + +// contentLengthFromReadSeeker determines the content length by seeking to the end. +func contentLengthFromReadSeeker(seeker io.ReadSeeker, expectedSize uint64) (int64, error) { + endOffset, err := seeker.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + + if expectedSize > 0 && expectedSize <= uint64(maxInt64) && endOffset != int64(expectedSize) { + log.Warningf("File size mismatch for S3 upload: expected %d bytes but reader reports %d bytes", expectedSize, endOffset) + } + + return endOffset, nil +} + +const maxInt64 = 1<<63 - 1 diff --git a/pkg/i18n/lang/de-DE.json b/pkg/i18n/lang/de-DE.json index b5452311d..d7f710d04 100644 --- a/pkg/i18n/lang/de-DE.json +++ b/pkg/i18n/lang/de-DE.json @@ -71,8 +71,8 @@ "message": "Dies ist eine freundliche Erinnerung an die Aufgabe „%[1]s“ (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s hat dich in einem Kommentar zu „%[2]s“ erwähnt", + "subject": "Re: %[1]s (%[2]s)", + "mentioned_subject": "%[1]s hat dich in einem Kommentar zu „%[2]s“ (%[3]s) erwähnt", "mentioned_message": "**%[1]s** hat dich in einem Kommentar erwähnt:" }, "assigned": { @@ -88,8 +88,8 @@ "message": "%[1]s hat die Aufgabe „%[2]s“ (%[3]s) gelöscht" }, "mentioned": { - "subject_new": "%[1]s hat dich in einer neuen Aufgabe „%[2]s“ erwähnt", - "subject": "%[1]s hat dich in einer Aufgabe „%[2]s“ erwähnt", + "subject_new": "%[1]s hat dich in einer neuen Aufgabe „%[2]s“ (%[3]s) erwähnt", + "subject": "%[1]s hat dich in einer Aufgabe „%[2]s“ (%[3]s) erwähnt", "message": "**%[1]s** hat dich in einer Aufgabe erwähnt:" }, "overdue": { @@ -137,7 +137,7 @@ "have_nice_day": "Hab einen schönen Tag!", "copy_url": "Falls der Button oben nicht funktioniert, kopiere die untenstehende URL und füge sie in die Adressleiste deines Browsers ein:", "actions": { - "open_task": "Aufgabe öffnen", + "open_task": "Aufgabe in Vikunja öffnen", "open_vikunja": "Vikunja öffnen", "open_project": "Projekt öffnen", "open_team": "Team öffnen", @@ -147,7 +147,14 @@ "confirm_email": "E-Mail-Adresse bestätigen", "abort_deletion": "Löschen abbrechen", "confirm_account_deletion": "Löschung meines Accounts bestätigen", - "change_notification_settings_link": "Du kannst deine Benachrichtigungseinstellungen [hier](%[1]s) ändern." + "change_notification_settings_link": "Du kannst deine Benachrichtigungseinstellungen [hier](%[1]s) ändern.", + "left_comment": "%[1]s hat kommentiert", + "mentioned_you_comment": "%[1]s hat dich in einem Kommentar erwähnt", + "mentioned_you": "%[1]s hat dich erwähnt", + "mentioned_you_new_task": "%[1]s hat dich in einer neuen Aufgabe erwähnt", + "assigned_you": "%[1]s hat dich zugewiesen", + "assigned_themselves": "%[1]s hat sich selbst zugewiesen", + "assigned_user": "%[1]s hat %[2]s zugewiesen" } } }, diff --git a/pkg/i18n/lang/de-swiss.json b/pkg/i18n/lang/de-swiss.json index b5452311d..d7f710d04 100644 --- a/pkg/i18n/lang/de-swiss.json +++ b/pkg/i18n/lang/de-swiss.json @@ -71,8 +71,8 @@ "message": "Dies ist eine freundliche Erinnerung an die Aufgabe „%[1]s“ (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s hat dich in einem Kommentar zu „%[2]s“ erwähnt", + "subject": "Re: %[1]s (%[2]s)", + "mentioned_subject": "%[1]s hat dich in einem Kommentar zu „%[2]s“ (%[3]s) erwähnt", "mentioned_message": "**%[1]s** hat dich in einem Kommentar erwähnt:" }, "assigned": { @@ -88,8 +88,8 @@ "message": "%[1]s hat die Aufgabe „%[2]s“ (%[3]s) gelöscht" }, "mentioned": { - "subject_new": "%[1]s hat dich in einer neuen Aufgabe „%[2]s“ erwähnt", - "subject": "%[1]s hat dich in einer Aufgabe „%[2]s“ erwähnt", + "subject_new": "%[1]s hat dich in einer neuen Aufgabe „%[2]s“ (%[3]s) erwähnt", + "subject": "%[1]s hat dich in einer Aufgabe „%[2]s“ (%[3]s) erwähnt", "message": "**%[1]s** hat dich in einer Aufgabe erwähnt:" }, "overdue": { @@ -137,7 +137,7 @@ "have_nice_day": "Hab einen schönen Tag!", "copy_url": "Falls der Button oben nicht funktioniert, kopiere die untenstehende URL und füge sie in die Adressleiste deines Browsers ein:", "actions": { - "open_task": "Aufgabe öffnen", + "open_task": "Aufgabe in Vikunja öffnen", "open_vikunja": "Vikunja öffnen", "open_project": "Projekt öffnen", "open_team": "Team öffnen", @@ -147,7 +147,14 @@ "confirm_email": "E-Mail-Adresse bestätigen", "abort_deletion": "Löschen abbrechen", "confirm_account_deletion": "Löschung meines Accounts bestätigen", - "change_notification_settings_link": "Du kannst deine Benachrichtigungseinstellungen [hier](%[1]s) ändern." + "change_notification_settings_link": "Du kannst deine Benachrichtigungseinstellungen [hier](%[1]s) ändern.", + "left_comment": "%[1]s hat kommentiert", + "mentioned_you_comment": "%[1]s hat dich in einem Kommentar erwähnt", + "mentioned_you": "%[1]s hat dich erwähnt", + "mentioned_you_new_task": "%[1]s hat dich in einer neuen Aufgabe erwähnt", + "assigned_you": "%[1]s hat dich zugewiesen", + "assigned_themselves": "%[1]s hat sich selbst zugewiesen", + "assigned_user": "%[1]s hat %[2]s zugewiesen" } } }, diff --git a/pkg/i18n/lang/en.json b/pkg/i18n/lang/en.json index 0645eda32..ec79274bc 100644 --- a/pkg/i18n/lang/en.json +++ b/pkg/i18n/lang/en.json @@ -71,8 +71,8 @@ "message": "This is a friendly reminder of the task \"%[1]s\" (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s mentioned you in a comment in \"%[2]s\"", + "subject": "Re: %[1]s (%[2]s)", + "mentioned_subject": "%[1]s mentioned you in a comment in \"%[2]s\" (%[3]s)", "mentioned_message": "**%[1]s** mentioned you in a comment:" }, "assigned": { @@ -88,8 +88,8 @@ "message": "%[1]s has deleted the task \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s mentioned you in a new task \"%[2]s\"", - "subject": "%[1]s mentioned you in a task \"%[2]s\"", + "subject_new": "%[1]s mentioned you in a new task \"%[2]s\" (%[3]s)", + "subject": "%[1]s mentioned you in a task \"%[2]s\" (%[3]s)", "message": "**%[1]s** mentioned you in a task:" }, "overdue": { @@ -137,7 +137,7 @@ "have_nice_day": "Have a nice day!", "copy_url": "If the button above doesn't work, copy the url below and paste it in your browser's address bar:", "actions": { - "open_task": "Open Task", + "open_task": "Open Task in Vikunja", "open_vikunja": "Open Vikunja", "open_project": "Open Project", "open_team": "Open Team", @@ -147,7 +147,14 @@ "confirm_email": "Confirm your email address", "abort_deletion": "Abort the deletion", "confirm_account_deletion": "Confirm the deletion of my account", - "change_notification_settings_link": "You can change your notification settings [here](%[1]s)." + "change_notification_settings_link": "You can change your notification settings [here](%[1]s).", + "left_comment": "%[1]s left a comment", + "mentioned_you_comment": "%[1]s mentioned you in a comment", + "mentioned_you": "%[1]s mentioned you", + "mentioned_you_new_task": "%[1]s mentioned you in a new task", + "assigned_you": "%[1]s assigned you", + "assigned_themselves": "%[1]s assigned themselves", + "assigned_user": "%[1]s assigned %[2]s" } } }, diff --git a/pkg/i18n/lang/fi-FI.json b/pkg/i18n/lang/fi-FI.json index 1bbd24499..c05811607 100644 --- a/pkg/i18n/lang/fi-FI.json +++ b/pkg/i18n/lang/fi-FI.json @@ -67,13 +67,9 @@ "message": "Tämä on ystävällinen muistutus tehtävästä \"%[1]s\" (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s mainitsi sinut kommentissa \"%[2]s\"", "mentioned_message": "**%[1]s** mainitsi sinut kommentissa:" }, "mentioned": { - "subject_new": "%[1]s mainitsi sinut uudessa tehtävässä \"%[2]s\"", - "subject": "%[1]s mainitsi sinut tehtävässä \"%[2]s\"", "message": "**%[1]s** mainitsi sinut tehtävässä:" }, "overdue": { @@ -105,7 +101,6 @@ "have_nice_day": "Hauskaa päivän jatkoa!", "copy_url": "Jos yläpuolella oleva nappi ei toimi, kopioi seuraava osoite selaimesi osoitepalkkiin:", "actions": { - "open_task": "Avaa Tehtävä", "open_vikunja": "Avaa Vikunja", "open_project": "Avaa Projekti", "open_team": "Avaa Tiimi", diff --git a/pkg/i18n/lang/fr-FR.json b/pkg/i18n/lang/fr-FR.json index bff72673e..a4767601a 100644 --- a/pkg/i18n/lang/fr-FR.json +++ b/pkg/i18n/lang/fr-FR.json @@ -71,8 +71,6 @@ "message": "Ceci est un petit rappel de la tâche \"%[1]s\" (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s vous a mentionné dans un commentaire dans \"%[2]s\"", "mentioned_message": "**%[1]s** vous a mentionné dans un commentaire :" }, "assigned": { @@ -86,8 +84,6 @@ "message": "%[1]s a supprimé la tâche \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s vous a mentionné dans une nouvelle tâche \"%[2]", - "subject": "%[1]s vous a mentionné dans une tâche \"%[2]", "message": "**%[1]s** vous a mentionné dans une tâche :" }, "overdue": { @@ -135,7 +131,6 @@ "have_nice_day": "Passez une bonne journée !", "copy_url": "Si le bouton ci-dessus ne fonctionne pas, copiez l'URL ci-dessous et collez-la dans la barre d'adresse de votre navigateur :", "actions": { - "open_task": "Ouvrir la tâche", "open_vikunja": "Ouvrir Vikunja", "open_project": "Ouvrir le projet", "open_team": "Ouvrir l'équipe", diff --git a/pkg/i18n/lang/he-IL.json b/pkg/i18n/lang/he-IL.json index 2db2be49b..99bcb6212 100644 --- a/pkg/i18n/lang/he-IL.json +++ b/pkg/i18n/lang/he-IL.json @@ -31,7 +31,6 @@ }, "common": { "actions": { - "open_task": "פתיחת מטלה", "open_project": "פתיחת פרויקט", "open_team": "פתיחת צוות", "download": "הורדה", diff --git a/pkg/i18n/lang/it-IT.json b/pkg/i18n/lang/it-IT.json index 4470a7fc1..38347d40e 100644 --- a/pkg/i18n/lang/it-IT.json +++ b/pkg/i18n/lang/it-IT.json @@ -71,8 +71,6 @@ "message": "Questo è un promemoria amichevole dell'attività \"%[1]s\" (%[2]s)." }, "comment": { - "subject": "Riguardo a %[1]s", - "mentioned_subject": "%[1]s ti ha menzionato in un commento in \"%[2]s\"", "mentioned_message": "**%[1]s** ti ha menzionato in un commento:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s ha eliminato l'attività \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s ti ha menzionato in una nuova attività \"%[2]s\"", - "subject": "%[1]s ti ha menzionato in un'attività \"%[2]s\"", "message": "**%[1]s** ti ha menzionato in un'attività:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "Buona giornata!", "copy_url": "Se il pulsante qui sopra non funziona, copia l'Url qui sotto e incollalo nella barra degli indirizzi del tuo browser:", "actions": { - "open_task": "Apri attività", "open_vikunja": "Apri Vikunja", "open_project": "Apri progetto", "open_team": "Apri Squadra", diff --git a/pkg/i18n/lang/ja-JP.json b/pkg/i18n/lang/ja-JP.json index 9bf923bf4..dfc117654 100644 --- a/pkg/i18n/lang/ja-JP.json +++ b/pkg/i18n/lang/ja-JP.json @@ -71,8 +71,6 @@ "message": "タスク「%[1]」(%[2]s) のリマインダーです。" }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s が「%[2]」のコメントであなたにメンションしました", "mentioned_message": "**%[1]s** があなたにメンションしました:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s がタスク「%[2]」(%[3]s) を削除しました" }, "mentioned": { - "subject_new": "%[1]s が新しいタスク「%[2]」であなたにメンションしました", - "subject": "%[1]s がタスク「%[2]」であなたにメンションしました", "message": "**%[1]s** があなたにメンションしました:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "よい一日を!", "copy_url": "上のボタンが機能しない場合は、以下のURLをコピーし、ブラウザのアドレスバーに貼り付けてください:", "actions": { - "open_task": "タスクを開く", "open_vikunja": "Vikunjaを開く", "open_project": "プロジェクトを開く", "open_team": "チームを開く", diff --git a/pkg/i18n/lang/ko-KR.json b/pkg/i18n/lang/ko-KR.json index aca83b219..cdde93021 100644 --- a/pkg/i18n/lang/ko-KR.json +++ b/pkg/i18n/lang/ko-KR.json @@ -71,8 +71,6 @@ "message": "이것은 \"%[1]s\" (%[2]s) 작업에 대한 친절한 알림입니다." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]이 \"%[2]s\"의 댓글에서 귀하를 언급했습니다.", "mentioned_message": "**%[1]s** 님이 댓글에서 귀하를 언급했습니다:" }, "assigned": { @@ -86,8 +84,6 @@ "message": "%[1]s 이(가) \"%[2]s\" (%[3]s) 작업을 삭제했습니다." }, "mentioned": { - "subject_new": "%[1]이 새 작업 \"%[2]s\"에서 귀하를 언급했습니다.", - "subject": "%[1]이 “%[2]s”의 작업에서 귀하를 언급했습니다.", "message": "**%[1]s**이(가) 작업에서 사용자를 언급했습니다:" }, "overdue": { @@ -135,7 +131,6 @@ "have_nice_day": "좋은 하루 보내세요!", "copy_url": "위 버튼이 작동하지 않는 경우 아래 URL을 복사하여 브라우저 주소창에 붙여넣으세요:", "actions": { - "open_task": "작업 열기", "open_vikunja": "Vikunja 열기", "open_project": "프로젝트 열기", "open_team": "오픈팀", diff --git a/pkg/i18n/lang/nl-NL.json b/pkg/i18n/lang/nl-NL.json index ce2a88249..0de4efe13 100644 --- a/pkg/i18n/lang/nl-NL.json +++ b/pkg/i18n/lang/nl-NL.json @@ -71,8 +71,6 @@ "message": "Dit is een vriendelijke herinnering voor taak \"%[1]s\" (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s noemde je in een reactie in \"%[2]s\"", "mentioned_message": "**%[1]s** noemde je in een reactie:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s verwijderde taak \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s noemde je in nieuwe taak \"%[2]s\"", - "subject": "%[1]s noemde je in een taak \"%[2]s\"", "message": "**%[1]s** noemde je in een taak:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "Fijne dag!", "copy_url": "Als bovenstaande knop niet werkt, kopieer dan onderstaande URL en plak deze in de adresbalk van je browser:", "actions": { - "open_task": "Taak openen", "open_vikunja": "Vikunja openen", "open_project": "Project openen", "open_team": "Team openen", diff --git a/pkg/i18n/lang/no-NO.json b/pkg/i18n/lang/no-NO.json index f4c10ac2b..398bc5564 100644 --- a/pkg/i18n/lang/no-NO.json +++ b/pkg/i18n/lang/no-NO.json @@ -71,8 +71,6 @@ "message": "Dette er en påminnelse om oppgaven «%[1]» (%[2]s)." }, "comment": { - "subject": "Sv: %[1]s", - "mentioned_subject": "%[1]s nevnte deg i en kommentar i \"%[2]s\"", "mentioned_message": "**%[1]s nevnte deg i en kommentar i \"%[]s:" }, "assigned": { @@ -86,8 +84,6 @@ "message": "%[1]s har slettet oppgaven \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s nevnte deg i en kommentar i \"%[2]s\"", - "subject": "%[1]s nevnt deg i en oppgave \"%[2]s\"", "message": "**%[1]s** nevnte deg i en oppgave:" }, "overdue": { @@ -133,7 +129,6 @@ "have_nice_day": "Ha en fin dag!", "copy_url": "Hvis knappen over ikke fungerer, kopier nettadressen under og lim den inn i adresselinjen i nettleseren:", "actions": { - "open_task": "Åpne oppgave", "open_vikunja": "Åpne Vikunja", "open_project": "Åpne prosjekt", "open_team": "Åpne gruppe", diff --git a/pkg/i18n/lang/pt-PT.json b/pkg/i18n/lang/pt-PT.json index a7024c2b2..3394e0dd4 100644 --- a/pkg/i18n/lang/pt-PT.json +++ b/pkg/i18n/lang/pt-PT.json @@ -71,8 +71,6 @@ "message": "Este é um lembrete cordial da tarefa “%[1]s” (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s mencionou-te num comentário em \"%[2]s\"", "mentioned_message": "**%[1]s** mencionou-te num comentário:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s eliminou a tarefa \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s mencionou-te numa nova tarefa \"%[2]s\"", - "subject": "%[1]s mencionou-te numa tarefa \"%[2]s\"", "message": "**%[1]s** mencionou-te numa tarefa:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "Tem um ótimo dia!", "copy_url": "Se o botão acima não funcionar, copia o url abaixo e cola-o na barra de endereços do teu browser:", "actions": { - "open_task": "Abrir Tarefa", "open_vikunja": "Abrir Vikunja", "open_project": "Abrir Projeto", "open_team": "Abrir Equipa", diff --git a/pkg/i18n/lang/ru-RU.json b/pkg/i18n/lang/ru-RU.json index f44823aae..1e0bce407 100644 --- a/pkg/i18n/lang/ru-RU.json +++ b/pkg/i18n/lang/ru-RU.json @@ -71,8 +71,8 @@ "message": "Это напоминание о задаче «%[1]s» (%[2]s)." }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s упомянул вас в комментарии в «%[2]s»", + "subject": "Re: %[1]s (%[2]s)", + "mentioned_subject": "%[1]s упомянул вас в комментарии в «%[2]s» (%[3]s)", "mentioned_message": "Пользователь **%[1]s** упомянул вас в комментарии:" }, "assigned": { @@ -88,8 +88,8 @@ "message": "Пользователь %[1]s удалил задачу «%[2]s» (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s упомянул вас в новой задаче «%[2]s»", - "subject": "%[1]s упомянул вас в задаче «%[2]s»", + "subject_new": "%[1]s упомянул вас в новой задаче «%[2]s» (%[3]s)", + "subject": "%[1]s упомянул вас в задаче «%[2]s» (%[3]s)", "message": "Пользователь **%[1]s** упомянул вас в задаче:" }, "overdue": { @@ -136,7 +136,7 @@ "have_nice_day": "Хорошего дня!", "copy_url": "Если ссылка выше не работает, скопируйте и вставьте в адресную строку ссылку отсюда:", "actions": { - "open_task": "Открыть задачу", + "open_task": "Открыть задачу в Vikunja", "open_vikunja": "Открыть Vikunja", "open_project": "Открыть проект", "open_team": "Открыть команду", @@ -146,7 +146,14 @@ "confirm_email": "Подтвердить email", "abort_deletion": "Отменить удаление", "confirm_account_deletion": "Подтвердить удаление аккаунта", - "change_notification_settings_link": "Вы можете изменить настройки уведомлений [здесь](%[1]s)." + "change_notification_settings_link": "Вы можете изменить настройки уведомлений [здесь](%[1]s).", + "left_comment": "Пользователь %[1]s оставил комментарий", + "mentioned_you_comment": "Пользователь %[1]s упомянул вас в комментарии", + "mentioned_you": "Пользователь %[1]s упомянул вас", + "mentioned_you_new_task": "Пользователь %[1]s упомянул вас в новой задаче", + "assigned_you": "Пользователь %[1]s назначил вас", + "assigned_themselves": "Пользователь %[1]s назначил себя", + "assigned_user": "Пользователь %[1]s назначил %[2]s" } } }, diff --git a/pkg/i18n/lang/sv-SE.json b/pkg/i18n/lang/sv-SE.json index 2031a1fe1..e3babe3a1 100644 --- a/pkg/i18n/lang/sv-SE.json +++ b/pkg/i18n/lang/sv-SE.json @@ -71,8 +71,6 @@ "message": "Det här är en vänlig påminnelse om uppgiften \"%[1]s\" (%[2])." }, "comment": { - "subject": "Ang: %[1]s", - "mentioned_subject": "%[1]s nämnde dig i en kommentar i \"%[2]s\"", "mentioned_message": "**%[1]s** nämnde dig i en kommentar:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s har raderat uppgiften \"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s nämnde dig i en ny uppgift \"%[2]s\"", - "subject": "%[1]s nämnde dig i en uppgift \"%[2]s\"", "message": "**%[1]s** nämnde dig i en uppgift:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "Ha en trevlig dag!", "copy_url": "Om knappen ovan inte fungerar, kopiera URL:en nedan och klistra in den i webbläsarens adressfält:", "actions": { - "open_task": "Öppna uppgift", "open_vikunja": "Öppna Vikunja", "open_project": "Öppna projekt", "open_team": "Öppna team", diff --git a/pkg/i18n/lang/tr-TR.json b/pkg/i18n/lang/tr-TR.json index 63f5f78c9..98c895e0b 100644 --- a/pkg/i18n/lang/tr-TR.json +++ b/pkg/i18n/lang/tr-TR.json @@ -71,8 +71,6 @@ "message": "Bu, \"%[1]s\" (%[2]s) görevi için dostça bir hatırlatmadır." }, "comment": { - "subject": "Ynt: %[1]s", - "mentioned_subject": "%[1]s, \"%[2]s\" yorumunda sizden bahsetti", "mentioned_message": "**%[1]s**, bir yorumda sizden bahsetti:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s, \"%[2]s\" (%[3]s) görevini sildi" }, "mentioned": { - "subject_new": "%[1]s, yeni bir görev olan \"%[2]s\" içinde sizden bahsetti", - "subject": "%[1]s, \"%[2]s\" görevinde sizden bahsetti", "message": "**%[1]s**, bir görevde sizden bahsetti:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "İyi günler dileriz!", "copy_url": "Yukarıdaki düğme çalışmazsa, aşağıdaki URL'yi kopyalayıp tarayıcınızın adres çubuğuna yapıştırın:", "actions": { - "open_task": "Görevi Aç", "open_vikunja": "Vikunja'yı Aç", "open_project": "Projeyi Aç", "open_team": "Takımı Aç", diff --git a/pkg/i18n/lang/vi-VN.json b/pkg/i18n/lang/vi-VN.json index 0cca2660f..5c87e541a 100644 --- a/pkg/i18n/lang/vi-VN.json +++ b/pkg/i18n/lang/vi-VN.json @@ -70,15 +70,12 @@ "subject": "Nhắc nhở cho \"%[1]s\" (%[2]s)" }, "comment": { - "mentioned_subject": "%[1]s đã nhắc đến bạn trong một bình luận của \"%[2]s\"", "mentioned_message": "**%[1]s** đã nhắc đến bạn trong một bình luận:" }, "assigned": { "message_to_others": "%[1]s đã giao tác vụ này cho %[2]s." }, "mentioned": { - "subject_new": "%[1]s nhắc đến bạn trong một tác vụ mới \"%[2]s\"", - "subject": "%[1]s đã nhắc đến bạn trong một tác vụ \"%[2]s\"", "message": "**%[1]s** đã nhắc đến bạn trong một tác vụ:" }, "overdue": { diff --git a/pkg/i18n/lang/zh-CN.json b/pkg/i18n/lang/zh-CN.json index 0c617bbc8..0bc41291f 100644 --- a/pkg/i18n/lang/zh-CN.json +++ b/pkg/i18n/lang/zh-CN.json @@ -69,8 +69,6 @@ "message": "这是一个友好的提醒任务\"%[1]s\" (%[2]s)。" }, "comment": { - "subject": "回复:%[1]s", - "mentioned_subject": "%[1]s在\"%[2]s\"的评论中提到了你", "mentioned_message": "**%[1]s**在评论中提到了您:" }, "assigned": { @@ -84,8 +82,6 @@ "message": "%[1]s已删除任务\"%[2]s\" (%[3]s)" }, "mentioned": { - "subject_new": "%[1]s在一个新任务\"%[2]s\"中提到了你", - "subject": "%[1]s在任务\"%[2]s\"中提到了你", "message": "**%[1]s** 在任务中提到了您:" }, "overdue": { @@ -132,7 +128,6 @@ "have_nice_day": "祝你有愉快的一天!", "copy_url": "如果上面的按钮无法工作,请复制下面的URL并将其粘贴到您的浏览器地址栏:", "actions": { - "open_task": "打开任务", "open_vikunja": "打开 Vikunja", "open_project": "打开项目", "open_team": "打开团队", diff --git a/pkg/i18n/lang/zh-TW.json b/pkg/i18n/lang/zh-TW.json index 84cf1987a..83787eb46 100644 --- a/pkg/i18n/lang/zh-TW.json +++ b/pkg/i18n/lang/zh-TW.json @@ -71,8 +71,6 @@ "message": "任務「%[1]s」(%[2]s) 提醒。" }, "comment": { - "subject": "Re: %[1]s", - "mentioned_subject": "%[1]s 在「%[2]s」的評論中提到您", "mentioned_message": "**%[1]s** 在評論中提到您:" }, "assigned": { @@ -88,8 +86,6 @@ "message": "%[1]s 已刪除任務「%[2]s」(%[3]s)" }, "mentioned": { - "subject_new": "%[1]s 在新任務「%[2]s」中提到您", - "subject": "%[1]s 在任務「%[2]s」中提到您", "message": "%[1]s 在任務中提到您:" }, "overdue": { @@ -137,7 +133,6 @@ "have_nice_day": "祝您有美好的一天!", "copy_url": "如果上方按鈕無法使用,請將以下網址複製到瀏覽器的網址列中:", "actions": { - "open_task": "開啟任務", "open_vikunja": "開啟 Vikunja", "open_project": "開啟專案", "open_team": "開啟團隊", diff --git a/pkg/migration/20260224215050.go b/pkg/migration/20260224215050.go new file mode 100644 index 000000000..18e7ed6b0 --- /dev/null +++ b/pkg/migration/20260224215050.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 migration + +import ( + "code.vikunja.io/api/pkg/config" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260224215050", + Description: "Add user_id to webhooks table and make project_id nullable", + Migrate: func(tx *xorm.Engine) error { + exists, err := columnExists(tx, "webhooks", "user_id") + if err != nil { + return err + } + if !exists { + if _, err = tx.Exec("ALTER TABLE webhooks ADD COLUMN user_id bigint NULL"); err != nil { + return err + } + } + + if _, err = tx.Exec("CREATE INDEX IF NOT EXISTS IDX_webhooks_user_id ON webhooks (user_id)"); err != nil { + return err + } + + // Make project_id nullable so user-level webhooks can have NULL project_id. + // SQLite does not support ALTER COLUMN, but it already allows NULL in bigint columns. + switch config.DatabaseType.GetString() { + case "mysql": + _, err = tx.Exec("ALTER TABLE webhooks MODIFY COLUMN project_id bigint NULL") + case "postgres": + _, err = tx.Exec("ALTER TABLE webhooks ALTER COLUMN project_id DROP NOT NULL") + } + if err != nil { + return err + } + + return nil + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index ccb4ee086..5dd7e92e7 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -152,6 +152,14 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b return true } + // Check if this is a bulk variant of a known CRUD resource + if strings.HasSuffix(routeGroupName, "_bulk") { + parent := strings.TrimSuffix(routeGroupName, "_bulk") + if crudResources[parent] { + return true + } + } + // Also check the base resource for nested paths if len(routeParts) > 0 && crudResources[routeParts[0]] { // For single-segment paths, it's CRUD if it's a known resource diff --git a/pkg/models/api_routes_test.go b/pkg/models/api_routes_test.go new file mode 100644 index 000000000..3dd9e7d99 --- /dev/null +++ b/pkg/models/api_routes_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 models + +import ( + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCanDoAPIRoute_BulkLabelTask(t *testing.T) { + // Reset apiTokenRoutes to isolate this test + apiTokenRoutes = make(map[string]APITokenRoute) + + // Register the standard CRUD routes for tasks_labels first + CollectRoutesForAPITokenUsage(echo.RouteInfo{ + Method: "PUT", + Path: "/api/v1/tasks/:projecttask/labels", + }, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{ + Method: "DELETE", + Path: "/api/v1/tasks/:projecttask/labels/:label", + }, true) + + // Now register the bulk route + CollectRoutesForAPITokenUsage(echo.RouteInfo{ + Method: "POST", + Path: "/api/v1/tasks/:projecttask/labels/bulk", + }, true) + + // Verify that the tasks_labels route group exists + routes, has := apiTokenRoutes["tasks_labels"] + require.True(t, has, "tasks_labels route group should exist") + + // The bulk route should be registered as "update_bulk" under tasks_labels + bulkRoute, has := routes["update_bulk"] + require.True(t, has, "update_bulk should exist in tasks_labels routes") + assert.Equal(t, "/api/v1/tasks/:projecttask/labels/bulk", bulkRoute.Path) + assert.Equal(t, "POST", bulkRoute.Method) +} diff --git a/pkg/models/events.go b/pkg/models/events.go index 593378884..658f8a1f7 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -178,8 +178,10 @@ func (t *TaskPositionsRecalculatedEvent) Name() string { // TaskReminderFiredEvent represents an event where a task reminder has fired type TaskReminderFiredEvent struct { - Task *Task `json:"task"` - Project *Project `json:"project"` + Task *Task `json:"task"` + User *user.User `json:"user"` + Project *Project `json:"project"` + Reminder *TaskReminder `json:"reminder"` } // Name defines the name for TaskReminderFiredEvent @@ -189,8 +191,9 @@ func (t *TaskReminderFiredEvent) Name() string { // TaskOverdueEvent represents an event where a task is overdue type TaskOverdueEvent struct { - Task *Task `json:"task"` - Project *Project `json:"project"` + Task *Task `json:"task"` + User *user.User `json:"user"` + Project *Project `json:"project"` } // Name defines the name for TaskOverdueEvent @@ -198,6 +201,18 @@ func (t *TaskOverdueEvent) Name() string { return "task.overdue" } +// TasksOverdueEvent represents an event where multiple tasks are overdue for a user +type TasksOverdueEvent struct { + Tasks []*Task `json:"tasks"` + User *user.User `json:"user"` + Projects map[int64]*Project `json:"projects"` +} + +// Name defines the name for TasksOverdueEvent +func (t *TasksOverdueEvent) Name() string { + return "tasks.overdue" +} + //////////////////// // Project Events // //////////////////// diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 4c456f82e..de83c9d58 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -86,8 +86,9 @@ func RegisterListeners() { RegisterEventForWebhook(&ProjectDeletedEvent{}) RegisterEventForWebhook(&ProjectSharedWithUserEvent{}) RegisterEventForWebhook(&ProjectSharedWithTeamEvent{}) - RegisterEventForWebhook(&TaskReminderFiredEvent{}) - RegisterEventForWebhook(&TaskOverdueEvent{}) + RegisterUserDirectedEventForWebhook(&TaskReminderFiredEvent{}) + RegisterUserDirectedEventForWebhook(&TaskOverdueEvent{}) + RegisterUserDirectedEventForWebhook(&TasksOverdueEvent{}) } } @@ -187,11 +188,17 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &TaskCommentNotification{ Doer: event.Doer, Task: event.Task, Comment: event.Comment, Mentioned: true, + Project: project, } mentionedUsers, err := notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n) if err != nil { @@ -218,6 +225,7 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) { Doer: event.Doer, Task: event.Task, Comment: event.Comment, + Project: project, } err = notifications.Notify(subscriber.User, n, sess) if err != nil { @@ -252,11 +260,17 @@ func (s *HandleTaskCommentEditMentions) Handle(msg *message.Message) (err error) sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &TaskCommentNotification{ Doer: event.Doer, Task: event.Task, Comment: event.Comment, Mentioned: true, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n) if err != nil { @@ -297,6 +311,11 @@ func (s *SendTaskAssignedNotification) Handle(msg *message.Message) (err error) return err } + project, err := GetProjectSimpleByID(sess, task.ProjectID) + if err != nil { + return err + } + notifiedUsers := make(map[int64]bool) for _, subscriber := range subscribers { @@ -314,6 +333,7 @@ func (s *SendTaskAssignedNotification) Handle(msg *message.Message) (err error) Task: &task, Assignee: event.Assignee, Target: subscriber.User, + Project: project, } err = notifications.Notify(subscriber.User, n, sess) if err != nil { @@ -401,10 +421,16 @@ func (s *HandleTaskCreateMentions) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &UserMentionedInTaskNotification{ - Task: event.Task, - Doer: event.Doer, - IsNew: true, + Task: event.Task, + Doer: event.Doer, + IsNew: true, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n) if err != nil { @@ -437,10 +463,16 @@ func (s *HandleTaskUpdatedMentions) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &UserMentionedInTaskNotification{ - Task: event.Task, - Doer: event.Doer, - IsNew: false, + Task: event.Task, + Doer: event.Doer, + IsNew: false, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n) @@ -801,6 +833,20 @@ func getProjectIDFromAnyEvent(eventPayload map[string]interface{}) int64 { return 0 } +func getUserIDFromAnyEvent(eventPayload map[string]interface{}) int64 { + if u, has := eventPayload["user"]; has { + userMap, ok := u.(map[string]interface{}) + if !ok { + return 0 + } + if userID, has := userMap["id"]; has { + return getIDAsInt64(userID) + } + } + + return 0 +} + func reloadDoerInEvent(s *xorm.Session, event map[string]interface{}) (doerID int64, err error) { doer, has := event["doer"] if !has || doer == nil { @@ -926,6 +972,33 @@ func reloadAssigneeInEvent(s *xorm.Session, event map[string]interface{}) error return nil } +func reloadUserInEvent(s *xorm.Session, event map[string]interface{}) error { + u, has := event["user"] + if !has || u == nil { + return nil + } + + userMap, ok := u.(map[string]interface{}) + if !ok { + return nil + } + + userID := getIDAsInt64(userMap["id"]) + if userID <= 0 { + return nil + } + + fullUser, err := user.GetUserByID(s, userID) + if err != nil && !user.IsErrUserDoesNotExist(err) { + return err + } + if err == nil { + event["user"] = fullUser + } + + return nil +} + func reloadEventData(s *xorm.Session, event map[string]interface{}, projectID int64) (eventWithData map[string]interface{}, doerID int64, err error) { // Load event data again so that it is always populated in the webhook payload @@ -949,6 +1022,11 @@ func reloadEventData(s *xorm.Session, event map[string]interface{}, projectID in return nil, doerID, err } + err = reloadUserInEvent(s, event) + if err != nil { + return nil, doerID, err + } + return event, doerID, nil } @@ -960,46 +1038,73 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { return err } + s := db.NewSession() + defer s.Close() + projectID := getProjectIDFromAnyEvent(event) - if projectID == 0 { + isUserDirected := IsUserDirectedEvent(wl.EventName) + + // For non-user-directed events, we need a project ID + if projectID == 0 && !isUserDirected { log.Debugf("event %s does not contain a project id, not handling webhook", wl.EventName) return nil } - s := db.NewSession() - defer s.Close() - - parents, err := GetAllParentProjects(s, projectID) - if err != nil { - return err - } - - projectIDs := make([]int64, 0, len(parents)+1) - projectIDs = append(projectIDs, projectID) - - for _, p := range parents { - projectIDs = append(projectIDs, p.ID) - } - - ws := []*Webhook{} - err = s.In("project_id", projectIDs). - Find(&ws) - if err != nil { - return err - } - + // Look up project-level webhooks matchingWebhooks := []*Webhook{} - for _, w := range ws { - for _, e := range w.Events { - if e == wl.EventName { - matchingWebhooks = append(matchingWebhooks, w) - break + if projectID > 0 { + parents, err := GetAllParentProjects(s, projectID) + if err != nil { + return err + } + + projectIDs := make([]int64, 0, len(parents)+1) + projectIDs = append(projectIDs, projectID) + for _, p := range parents { + projectIDs = append(projectIDs, p.ID) + } + + ws := []*Webhook{} + err = s.In("project_id", projectIDs). + Find(&ws) + if err != nil { + return err + } + + for _, w := range ws { + for _, e := range w.Events { + if e == wl.EventName { + matchingWebhooks = append(matchingWebhooks, w) + break + } + } + } + } + + // Look up user-level webhooks for user-directed events + if isUserDirected { + userID := getUserIDFromAnyEvent(event) + if userID > 0 { + userWebhooks := []*Webhook{} + err = s.Where("user_id = ? AND (project_id IS NULL OR project_id = 0)", userID). + Find(&userWebhooks) + if err != nil { + return err + } + + for _, w := range userWebhooks { + for _, e := range w.Events { + if e == wl.EventName { + matchingWebhooks = append(matchingWebhooks, w) + break + } + } } } } if len(matchingWebhooks) == 0 { - log.Debugf("Did not find any webhook for the %s event for project %d, not sending", wl.EventName, projectID) + log.Debugf("Did not find any webhook for the %s event, not sending", wl.EventName) return nil } @@ -1010,8 +1115,7 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { } for _, webhook := range matchingWebhooks { - - if _, has := event["project"]; !has { + if _, has := event["project"]; !has && webhook.ProjectID > 0 { project, err := GetProjectSimpleByID(s, webhook.ProjectID) if err != nil && !IsErrProjectDoesNotExist(err) { log.Errorf("Could not load project for webhook %d: %s", webhook.ID, err) diff --git a/pkg/models/mentions_test.go b/pkg/models/mentions_test.go index 4d8065807..a0c797ea9 100644 --- a/pkg/models/mentions_test.go +++ b/pkg/models/mentions_test.go @@ -117,6 +117,8 @@ func TestSendingMentionNotification(t *testing.T) { task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) + project, err := GetProjectSimpleByID(s, task.ProjectID) + require.NoError(t, err) tc := &TaskComment{ Comment: `

Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6

`, TaskID: 32, // user2 has access to the project that task belongs to @@ -127,6 +129,7 @@ func TestSendingMentionNotification(t *testing.T) { Doer: u, Task: &task, Comment: tc, + Project: project, } _, err = notifyMentionedUsers(s, &task, tc.Comment, n) @@ -171,6 +174,8 @@ func TestSendingMentionNotification(t *testing.T) { task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) + project, err := GetProjectSimpleByID(s, task.ProjectID) + require.NoError(t, err) tc := &TaskComment{ Comment: `

Lorem Ipsum @user2

`, TaskID: 32, // user2 has access to the project that task belongs to @@ -181,6 +186,7 @@ func TestSendingMentionNotification(t *testing.T) { Doer: u, Task: &task, Comment: tc, + Project: project, } _, err = notifyMentionedUsers(s, &task, tc.Comment, n) diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 78266b242..2601f5adc 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -26,11 +26,22 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/i18n" + "code.vikunja.io/api/pkg/modules/avatar" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" ) +// getDoerAvatarDataURI returns the avatar data URI for a user, for use in email headers. +func getDoerAvatarDataURI(doer *user.User) string { + provider := avatar.GetProvider(doer) + dataURI, err := provider.AsDataURI(doer, 20) + if err != nil { + return "" + } + return dataURI +} + // getThreadID generates a Message-ID format thread ID for a task func getThreadID(taskID int64) string { domain := "vikunja" @@ -45,9 +56,10 @@ func getThreadID(taskID int64) string { // ReminderDueNotification represents a ReminderDueNotification notification type ReminderDueNotification struct { - User *user.User `json:"user,omitempty"` - Task *Task `json:"task"` - Project *Project `json:"project"` + User *user.User `json:"user,omitempty"` + Task *Task `json:"task"` + Project *Project `json:"project"` + TaskReminder *TaskReminder `json:"reminder"` } // ToMail returns the mail notification for ReminderDueNotification @@ -86,6 +98,7 @@ type TaskCommentNotification struct { Task *Task `json:"task"` Comment *TaskComment `json:"comment"` Mentioned bool `json:"mentioned"` + Project *Project `json:"project"` } func (n *TaskCommentNotification) SubjectID() int64 { @@ -99,19 +112,33 @@ func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail { formattedComment := formatMentionsForEmail(s, n.Comment.Comment) mail := notifications.NewMail(). + Conversational(). From(n.Doer.GetNameAndFromEmail()). - Subject(i18n.T(lang, "notifications.task.comment.subject", n.Task.Title)) + Subject(i18n.T(lang, "notifications.task.comment.subject", n.Task.Title, n.Task.GetFullIdentifier())) + // Add header line + action := i18n.T(lang, "notifications.common.actions.left_comment", n.Doer.GetName()) if n.Mentioned { - mail. - Line(i18n.T(lang, "notifications.task.comment.mentioned_message", n.Doer.GetName())). - Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title)) + action = i18n.T(lang, "notifications.common.actions.mentioned_you_comment", n.Doer.GetName()) + mail.Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())) } + headerLine := notifications.CreateConversationalHeader( + getDoerAvatarDataURI(n.Doer), + action, + n.Task.GetFrontendURL(), + n.Project.Title, + n.Task.GetFullIdentifier(), + n.Task.Title, + ) + mail.HeaderLine(headerLine) + + // Add the actual comment content wrapped in a div for consistent spacing mail.HTML(formattedComment) return mail. - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the TaskCommentNotification notification in a format which can be saved in the db @@ -135,29 +162,41 @@ type TaskAssignedNotification struct { Task *Task `json:"task"` Assignee *user.User `json:"assignee"` Target *user.User `json:"-"` + Project *Project `json:"project"` } // ToMail returns the mail notification for TaskAssignedNotification func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail { if n.Target.ID == n.Assignee.ID { + // Notification to the assignee return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_assignee", n.Doer.GetName(), n.Task.Title)). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // Check if the doer assigned the task to themselves if n.Doer.ID == n.Assignee.ID { return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_others_self", n.Doer.GetName())). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } + // Notification to others about assignment return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_others", n.Doer.GetName(), n.Assignee.GetName())). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the TaskAssignedNotification notification in a format which can be saved in the db @@ -343,9 +382,10 @@ func (n *UndoneTasksOverdueNotification) Name() string { // UserMentionedInTaskNotification represents a UserMentionedInTaskNotification notification type UserMentionedInTaskNotification struct { - Doer *user.User `json:"doer"` - Task *Task `json:"task"` - IsNew bool `json:"is_new"` + Doer *user.User `json:"doer"` + Task *Task `json:"task"` + IsNew bool `json:"is_new"` + Project *Project `json:"project"` } func (n *UserMentionedInTaskNotification) SubjectID() int64 { @@ -360,19 +400,39 @@ func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mai var subject string if n.IsNew { - subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title) + subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()) } else { - subject = i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title) + subject = i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()) } mail := notifications.NewMail(). + Conversational(). From(n.Doer.GetNameAndFromEmail()). - Subject(subject). - Line(i18n.T(lang, "notifications.task.mentioned.message", n.Doer.GetName())). - HTML(formattedDescription) + Subject(subject) + + // Add header line + action := i18n.T(lang, "notifications.common.actions.mentioned_you", n.Doer.GetName()) + if n.IsNew { + action = i18n.T(lang, "notifications.common.actions.mentioned_you_new_task", n.Doer.GetName()) + } + + headerLine := notifications.CreateConversationalHeader( + getDoerAvatarDataURI(n.Doer), + action, + n.Task.GetFrontendURL(), + n.Project.Title, + n.Task.GetFullIdentifier(), + n.Task.Title, + ) + mail.HeaderLine(headerLine) + + if formattedDescription != "" { + mail.HTML(formattedDescription) + } return mail. - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the UserMentionedInTaskNotification notification in a format which can be saved in the db diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 651865fee..d0f913210 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -17,6 +17,9 @@ package models import ( + "bytes" + "io" + "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/utils" @@ -301,7 +304,12 @@ func duplicateProjectBackground(s *xorm.Session, pd *ProjectDuplicate, doer web. } defer f.File.Close() - file, err := files.CreateWithSession(s, f.File, f.Name, f.Size, doer) + buf, err := io.ReadAll(f.File) + if err != nil { + return err + } + + file, err := files.CreateWithSession(s, bytes.NewReader(buf), f.Name, f.Size, doer) if err != nil { return err } @@ -388,15 +396,19 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate) (newTa return nil, err } - err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer) + buf, err := io.ReadAll(attachment.File.File) if err != nil { return nil, err } - if attachment.File.File != nil { _ = attachment.File.File.Close() } + err = attachment.NewAttachment(s, bytes.NewReader(buf), attachment.File.Name, attachment.File.Size, doer) + if err != nil { + return nil, err + } + log.Debugf("Duplicated attachment %d into %d from project %d into %d", oldAttachmentID, attachment.ID, ld.ProjectID, ld.Project.ID) } diff --git a/pkg/models/project_test.go b/pkg/models/project_test.go index 99431c6d4..0c30bdb6e 100644 --- a/pkg/models/project_test.go +++ b/pkg/models/project_test.go @@ -531,9 +531,22 @@ func TestProject_ReadAll(t *testing.T) { require.NoError(t, err) ls := projects3.([]*Project) - require.Len(t, ls, 2) - assert.Equal(t, int64(10), ls[0].ID) - assert.Equal(t, int64(-1), ls[1].ID) + + if db.ParadeDBAvailable() { + // ParadeDB fuzzy(1, prefix=true) on "TEST10" also matches + // "test1", "test11", "test19", "test30" (edit distance 1), etc. + require.Len(t, ls, 6) + projectIDs := make([]int64, len(ls)) + for i, p := range ls { + projectIDs[i] = p.ID + } + assert.Contains(t, projectIDs, int64(10)) + assert.Contains(t, projectIDs, int64(-1)) + } else { + require.Len(t, ls, 2) + assert.Equal(t, int64(10), ls[0].ID) + assert.Equal(t, int64(-1), ls[1].ID) + } }) t.Run("search returns filters as well", func(t *testing.T) { db.LoadAndAssertFixtures(t) diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go index 60353a495..8605ea06c 100644 --- a/pkg/models/setup_tests.go +++ b/pkg/models/setup_tests.go @@ -75,6 +75,8 @@ func SetupTests() { "task_positions", "task_buckets", "sessions", + "webhooks", + "totp", ) if err != nil { log.Fatal(err) diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index 9d9045704..3f95f412a 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -18,6 +18,7 @@ package models import ( "bytes" + "fmt" "image" "image/png" "io" @@ -221,7 +222,25 @@ func (ta *TaskAttachment) GetPreview(previewSize PreviewSize) []byte { cacheKey := cacheKeyForTaskAttachmentPreview(ta.ID, previewSize) result, err := keyvalue.Remember(cacheKey, func() (any, error) { - img, _, err := image.Decode(ta.File.File) + // Read all bytes up front so we can inspect dimensions without seeking. + // The file is an io.ReadCloser (no Seek), so we buffer it once. + data, err := io.ReadAll(ta.File.File) + if err != nil { + return nil, err + } + + // Check image dimensions before full decode to prevent DoS + // from decompression bombs (small file, huge pixel dimensions) + const maxPixels = 50_000_000 // 50 megapixels + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if cfg.Width*cfg.Height > maxPixels { + return nil, fmt.Errorf("image dimensions %dx%d exceed maximum of %d pixels", cfg.Width, cfg.Height, maxPixels) + } + + img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/pkg/models/task_attachment_test.go b/pkg/models/task_attachment_test.go index c2cc7dcc2..a2e06a365 100644 --- a/pkg/models/task_attachment_test.go +++ b/pkg/models/task_attachment_test.go @@ -18,11 +18,12 @@ package models import ( "bytes" + "image" + "image/png" + "io" "os" - "path/filepath" "testing" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/user" @@ -51,7 +52,12 @@ func TestTaskAttachment_ReadOne(t *testing.T) { // Load the actual attachment file and check its content err = ta.File.LoadFileByID() require.NoError(t, err) - assert.Equal(t, filepath.Join(config.ServiceRootpath.GetString(), "files", "1"), ta.File.File.Name()) + + // Validate the file exists at the expected storage path + stat, err := files.FileStat(ta.File) + require.NoError(t, err) + assert.NotNil(t, stat) + content := make([]byte, 9) read, err := ta.File.File.Read(content) require.NoError(t, err) @@ -181,6 +187,25 @@ func TestTaskAttachment_Delete(t *testing.T) { }) } +func TestAttachmentPreviewRejectsLargeImages(t *testing.T) { + // Create a 10000x10000 pixel image (100M pixels, well above the 50M limit) + // As a PNG this is small on disk but huge when decoded into memory + img := image.NewNRGBA(image.Rect(0, 0, 10000, 10000)) + var buf bytes.Buffer + err := png.Encode(&buf, img) + require.NoError(t, err) + + attachment := &TaskAttachment{ + ID: 999999, + File: &files.File{ + File: io.NopCloser(bytes.NewReader(buf.Bytes())), + }, + } + + result := attachment.GetPreview(PreviewMedium) + assert.Nil(t, result, "Preview should be nil for images exceeding max pixel count") +} + func TestTaskAttachment_Permissions(t *testing.T) { u := &user.User{ID: 1} t.Run("Can Read", func(t *testing.T) { diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index c4841804e..06a1799e1 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -56,7 +56,8 @@ type TaskCollection struct { // If set to `reactions`, the reactions of each task will be present in the response. // If set to `comments`, the first 50 comments of each task will be present in the response. // You can set this multiple times with different values. - Expand []TaskCollectionExpandable `query:"expand[]" json:"-"` + Expand []TaskCollectionExpandable `query:"expand" json:"-"` + ExpandArr []TaskCollectionExpandable `query:"expand[]" json:"-"` isSavedFilter bool @@ -114,6 +115,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie tf.OrderBy = append(tf.OrderBy, tf.OrderByArr...) } + if len(tf.ExpandArr) > 0 { + tf.Expand = append(tf.Expand, tf.ExpandArr...) + } + var sort = make([]*sortParam, 0, len(tf.SortBy)) for i, s := range tf.SortBy { param := &sortParam{ @@ -241,7 +246,7 @@ func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter // @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature." // @Param filter_timezone query string false "The time zone which should be used for date match (statements like "now" resolve to different actual times)" // @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." -// @Param expand query array false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." +// @Param expand query string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." // @Security JWTKeyAuth // @Success 200 {array} models.Task "The tasks" // @Failure 500 {object} models.Message "Internal error" @@ -292,7 +297,8 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa tc.ProjectViewID = tf.ProjectViewID tc.ProjectID = tf.ProjectID tc.isSavedFilter = true - tc.Expand = tf.Expand + tc.Expand = append(tf.Expand, tf.ExpandArr...) + tc.ExpandArr = nil if tf.Filter != "" { if tc.Filter != "" { diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 73423001d..a455b2065 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -683,6 +683,19 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } + task48 := &Task{ + ID: 48, + Title: "Landingpages update", + Description: "Update all landingpages with new branding", + Identifier: "test1-33", + Index: 33, + CreatedByID: 1, + CreatedBy: user1, + ProjectID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: time.Unix(1543626724, 0).In(loc), + Updated: time.Unix(1543626724, 0).In(loc), + } type fields struct { ProjectID int64 @@ -765,6 +778,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, task39, task47, + task48, }, wantErr: false, }, @@ -811,6 +825,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, task39, task47, + task48, }, wantErr: false, }, @@ -823,6 +838,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { }, args: defaultArgs, want: []*Task{ + task48, task47, task35, task33, @@ -976,6 +992,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task33, task35, task47, + task48, }, wantErr: false, }, @@ -1043,6 +1060,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, // has nil dates task39, // has nil dates task47, // has nil dates + task48, // has nil dates }, wantErr: false, }, @@ -1216,6 +1234,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, task39, task47, + task48, }, wantErr: false, }, @@ -1311,6 +1330,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, task39, task47, + task48, }, wantErr: false, }, @@ -1388,6 +1408,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task35, task39, task47, + task48, }, wantErr: false, }, @@ -1433,6 +1454,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { // task 35 has a label 5 and 4 task39, task47, + task48, }, wantErr: false, }, @@ -1478,6 +1500,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { // task 35 has a label 5 and 4 task39, task47, + task48, }, wantErr: false, }, @@ -1535,6 +1558,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task33, task39, task47, + task48, }, wantErr: false, }, @@ -1626,6 +1650,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task31, task33, task47, + task48, }, }, { @@ -1643,6 +1668,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { task5, task28, // The other ones don't have a due date + task48, task47, task39, task35, @@ -1740,7 +1766,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { // Here we're explicitly testing search with and without paradeDB. Both return different results but that's // expected - paradeDB returns more results than other databases with a naive like-search. - if db.ParadeDBAvailable() { + if !db.ParadeDBAvailable() { tests = append(tests, testcase{ name: "search for task index", fields: fields{}, @@ -1750,24 +1776,31 @@ func TestTaskCollection_ReadAll(t *testing.T) { page: 0, }, want: []*Task{ - task17, // has the text #17 in the title task33, // has the index 17 }, wantErr: false, }) - } else { - tests = append(tests, testcase{ - name: "search for task index", - fields: fields{}, - args: args{ - search: "number #17", - a: &user.User{ID: 1}, - page: 0, - }, - want: []*Task{ - task33, // has the index 17 - }, - wantErr: false, + } + + if db.ParadeDBAvailable() { + // ParadeDB fuzzy(1, prefix=true) on "17" also matches tokens within + // edit distance 1 ("1", "7", "10"-"19", "27", "47"), returning more results. + t.Run("search for task index", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + lt := &TaskCollection{} + got, _, _, err := lt.ReadAll(s, &user.User{ID: 1}, "number #17", 0, 50) + require.NoError(t, err) + gotTasks := got.([]*Task) + require.Len(t, gotTasks, 14) + gotIDs := make([]int64, len(gotTasks)) + for i, tsk := range gotTasks { + gotIDs[i] = tsk.ID + } + assert.Contains(t, gotIDs, task17.ID, "should contain task #17 (has #17 in title)") + assert.Contains(t, gotIDs, task33.ID, "should contain task #33 (has index 17)") }) } diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go index 45f9f8f23..4e9ab0ffa 100644 --- a/pkg/models/task_comments.go +++ b/pkg/models/task_comments.go @@ -194,10 +194,17 @@ func (tc *TaskComment) Update(s *xorm.Session, _ web.Auth) error { } func getTaskCommentSimple(s *xorm.Session, tc *TaskComment) error { - exists, err := s. + query := s. Where("id = ?", tc.ID). - NoAutoCondition(). - Get(tc) + NoAutoCondition() + + // When TaskID is provided (e.g. from URL parameters), verify the comment + // belongs to that task to prevent IDOR attacks. + if tc.TaskID != 0 { + query = query.And("task_id = ?", tc.TaskID) + } + + exists, err := query.Get(tc) if err != nil { return err } diff --git a/pkg/models/task_duplicate.go b/pkg/models/task_duplicate.go index ea65038e4..7d3bde995 100644 --- a/pkg/models/task_duplicate.go +++ b/pkg/models/task_duplicate.go @@ -17,6 +17,9 @@ package models import ( + "bytes" + "io" + "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/web" @@ -131,7 +134,11 @@ func (td *TaskDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { sourceFile := attachment.File.File defer sourceFile.Close() - err := attachment.NewAttachment(s, sourceFile, attachment.File.Name, attachment.File.Size, doer) + buf, err := io.ReadAll(sourceFile) + if err != nil { + return err + } + err = attachment.NewAttachment(s, bytes.NewReader(buf), attachment.File.Name, attachment.File.Size, doer) if err != nil { return err } diff --git a/pkg/models/task_overdue_reminder.go b/pkg/models/task_overdue_reminder.go index 2b91f1741..e10ef126e 100644 --- a/pkg/models/task_overdue_reminder.go +++ b/pkg/models/task_overdue_reminder.go @@ -32,7 +32,7 @@ import ( "xorm.io/xorm" ) -func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[int64]*userWithTasks, err error) { +func getUndoneOverdueTasks(s *xorm.Session, now time.Time, cond builder.Cond) (usersWithTasks map[int64]*userWithTasks, err error) { now = utils.GetTimeWithoutSeconds(now) nextMinute := now.Add(1 * time.Minute) @@ -55,7 +55,7 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[i taskIDs = append(taskIDs, task.ID) } - users, err := getTaskUsersForTasks(s, taskIDs, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) + users, err := getTaskUsersForTasks(s, taskIDs, cond) if err != nil { return } @@ -111,17 +111,38 @@ type userWithTasks struct { // RegisterOverdueReminderCron registers a function which checks once a day for tasks that are overdue and not done. func RegisterOverdueReminderCron() { + webhookEnabled := config.WebhooksEnabled.GetBool() + emailEnabled := config.ServiceEnableEmailReminders.GetBool() && config.MailerEnabled.GetBool() + + if !emailEnabled && !webhookEnabled { + return + } + + if !emailEnabled { + log.Info("Mailer is disabled, not sending overdue reminders per mail") + } + err := cron.Schedule("* * * * *", func() { s := db.NewSession() defer s.Close() now := time.Now() - uts, err := getUndoneOverdueTasks(s, now) + + var cond builder.Cond + if emailEnabled && !webhookEnabled { + cond = builder.Eq{"users.overdue_tasks_reminders_enabled": true} + } + + uts, err := getUndoneOverdueTasks(s, now, cond) if err != nil { log.Errorf("[Undone Overdue Tasks Reminder] Could not get undone overdue tasks in the next minute: %s", err) return } + if len(uts) == 0 { + return + } + log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(uts)) taskIDs := []int64{} @@ -137,54 +158,57 @@ func RegisterOverdueReminderCron() { return } - // Dispatch webhook events, deduplicated by task ID across all users - dispatchedTasks := make(map[int64]bool) for _, ut := range uts { - for _, t := range ut.tasks { - if dispatchedTasks[t.ID] { - continue + if emailEnabled && ut.user.OverdueTasksRemindersEnabled { + var n notifications.Notification = &UndoneTasksOverdueNotification{ + User: ut.user, + Tasks: ut.tasks, + Projects: projects, } - dispatchedTasks[t.ID] = true - err = events.Dispatch(&TaskOverdueEvent{ - Task: t, - Project: projects[t.ProjectID], - }) - if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not dispatch overdue event for task %d: %s", t.ID, err) - } - } - } - if !config.ServiceEnableEmailReminders.GetBool() || !config.MailerEnabled.GetBool() { - return - } - - for _, ut := range uts { - var n notifications.Notification = &UndoneTasksOverdueNotification{ - User: ut.user, - Tasks: ut.tasks, - Projects: projects, - } - - if len(ut.tasks) == 1 { - // We know there's only one entry in the map so this is actually O(1) and we can use it to get the - // first entry without knowing the key of it. - for _, t := range ut.tasks { - n = &UndoneTaskOverdueNotification{ - User: ut.user, - Task: t, - Project: projects[t.ProjectID], + if len(ut.tasks) == 1 { + for _, t := range ut.tasks { + n = &UndoneTaskOverdueNotification{ + User: ut.user, + Task: t, + Project: projects[t.ProjectID], + } } } + + err = notifications.Notify(ut.user, n, s) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err) + return + } } - err = notifications.Notify(ut.user, n, s) - if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err) - return + // Dispatch webhook events + if webhookEnabled { + // Per-task events + for _, t := range ut.tasks { + err = events.Dispatch(&TaskOverdueEvent{ + Task: t, + User: ut.user, + Project: projects[t.ProjectID], + }) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not dispatch overdue event for task %d: %s", t.ID, err) + } + } + + // Batch event + err = events.Dispatch(&TasksOverdueEvent{ + Tasks: mapToSlice(ut.tasks), + User: ut.user, + Projects: projects, + }) + if err != nil { + log.Errorf("[Undone Overdue Tasks Reminder] Could not dispatch batch overdue event for user %d: %s", ut.user.ID, err) + } } - log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder email for %d tasks to user %d", len(ut.tasks), ut.user.ID) + log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder for %d tasks to user %d", len(ut.tasks), ut.user.ID) } if err := s.Commit(); err != nil { @@ -195,3 +219,11 @@ func RegisterOverdueReminderCron() { log.Fatalf("Could not register undone overdue tasks reminder cron: %s", err) } } + +func mapToSlice(m map[int64]*Task) []*Task { + tasks := make([]*Task, 0, len(m)) + for _, t := range m { + tasks = append(tasks, t) + } + return tasks +} diff --git a/pkg/models/task_overdue_reminder_test.go b/pkg/models/task_overdue_reminder_test.go index 4a465b03e..e02cd2619 100644 --- a/pkg/models/task_overdue_reminder_test.go +++ b/pkg/models/task_overdue_reminder_test.go @@ -35,7 +35,7 @@ func TestGetUndoneOverDueTasks(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-01-01T01:13:00Z") require.NoError(t, err) - tasks, err := getUndoneOverdueTasks(s, now) + tasks, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) require.NoError(t, err) assert.Empty(t, tasks) }) @@ -46,7 +46,7 @@ func TestGetUndoneOverDueTasks(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-12-01T09:00:00Z") require.NoError(t, err) - uts, err := getUndoneOverdueTasks(s, now) + uts, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) require.NoError(t, err) require.Len(t, uts, 1) assert.Len(t, uts[1].tasks, 2) @@ -71,7 +71,7 @@ func TestGetUndoneOverDueTasks(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-11-01T01:13:00Z") require.NoError(t, err) - tasks, err := getUndoneOverdueTasks(s, now) + tasks, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) require.NoError(t, err) assert.Empty(t, tasks) }) diff --git a/pkg/models/task_reminder.go b/pkg/models/task_reminder.go index 2257600ea..3fddbc49d 100644 --- a/pkg/models/task_reminder.go +++ b/pkg/models/task_reminder.go @@ -241,7 +241,7 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64, cond builder.Cond) ( return } -func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (reminderNotifications []*ReminderDueNotification, err error) { +func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time, cond builder.Cond) (reminderNotifications []*ReminderDueNotification, err error) { now = utils.GetTimeWithoutNanoSeconds(now) reminderNotifications = []*ReminderDueNotification{} @@ -275,7 +275,7 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi return } - usersWithReminders, err := getTaskUsersForTasks(s, taskIDs, builder.Eq{"users.email_reminders_enabled": true}) + usersWithReminders, err := getTaskUsersForTasks(s, taskIDs, cond) if err != nil { return } @@ -327,9 +327,10 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi seen[r.TaskID][u.User.ID] = true reminderNotifications = append(reminderNotifications, &ReminderDueNotification{ - User: u.User, - Task: u.Task, - Project: projects[u.Task.ProjectID], + User: u.User, + Task: u.Task, + Project: projects[u.Task.ProjectID], + TaskReminder: r, }) } } @@ -341,8 +342,18 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi // RegisterReminderCron registers a cron function which runs every minute to check if any reminders are due the // next minute to send emails. func RegisterReminderCron() { - tz := config.GetTimeZone() + webhookEnabled := config.WebhooksEnabled.GetBool() + emailEnabled := config.ServiceEnableEmailReminders.GetBool() && config.MailerEnabled.GetBool() + if !emailEnabled && !webhookEnabled { + return + } + + if !emailEnabled { + log.Info("Mailer is disabled, not sending reminders per mail") + } + + tz := config.GetTimeZone() log.Debugf("[Task Reminder Cron] Timezone is %s", tz) err := cron.Schedule("* * * * *", func() { @@ -350,7 +361,16 @@ func RegisterReminderCron() { defer s.Close() now := time.Now() - reminders, err := getTasksWithRemindersDueAndTheirUsers(s, now) + + // When only email is enabled, filter to email-enabled users for efficiency. + // When webhooks are enabled, we need all users so the event system can + // look up matching webhooks. + var cond builder.Cond + if emailEnabled && !webhookEnabled { + cond = builder.Eq{"users.email_reminders_enabled": true} + } + + reminders, err := getTasksWithRemindersDueAndTheirUsers(s, now, cond) if err != nil { log.Errorf("[Task Reminder Cron] Could not get tasks with reminders in the next minute: %s", err) return @@ -362,34 +382,28 @@ func RegisterReminderCron() { log.Debugf("[Task Reminder Cron] Sending %d reminders", len(reminders)) - // Dispatch webhook events, deduplicated by task ID - dispatchedTasks := make(map[int64]bool) for _, n := range reminders { - if dispatchedTasks[n.Task.ID] { - continue - } - dispatchedTasks[n.Task.ID] = true - err = events.Dispatch(&TaskReminderFiredEvent{ - Task: n.Task, - Project: n.Project, - }) - if err != nil { - log.Errorf("[Task Reminder Cron] Could not dispatch reminder event for task %d: %s", n.Task.ID, err) - } - } - - if !config.ServiceEnableEmailReminders.GetBool() || !config.MailerEnabled.GetBool() { - return - } - - for _, n := range reminders { - err = notifications.Notify(n.User, n, s) - if err != nil { - log.Errorf("[Task Reminder Cron] Could not notify user %d: %s", n.User.ID, err) - return + if emailEnabled && n.User.EmailRemindersEnabled { + err = notifications.Notify(n.User, n, s) + if err != nil { + log.Errorf("[Task Reminder Cron] Could not notify user %d: %s", n.User.ID, err) + return + } } - log.Debugf("[Task Reminder Cron] Sent reminder email for task %d to user %d", n.Task.ID, n.User.ID) + if webhookEnabled { + err = events.Dispatch(&TaskReminderFiredEvent{ + Task: n.Task, + User: n.User, + Project: n.Project, + Reminder: n.TaskReminder, + }) + if err != nil { + log.Errorf("[Task Reminder Cron] Could not dispatch reminder event for task %d: %s", n.Task.ID, err) + } + } + + log.Debugf("[Task Reminder Cron] Sent reminder for task %d to user %d", n.Task.ID, n.User.ID) } if err := s.Commit(); err != nil { diff --git a/pkg/models/task_reminder_test.go b/pkg/models/task_reminder_test.go index 46cf15543..62216c1b1 100644 --- a/pkg/models/task_reminder_test.go +++ b/pkg/models/task_reminder_test.go @@ -22,6 +22,8 @@ import ( "code.vikunja.io/api/pkg/db" + "xorm.io/builder" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,7 +36,7 @@ func TestReminderGetTasksInTheNextMinute(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-12-01T01:12:00Z") require.NoError(t, err) - notifications, err := getTasksWithRemindersDueAndTheirUsers(s, now) + notifications, err := getTasksWithRemindersDueAndTheirUsers(s, now, builder.Eq{"users.email_reminders_enabled": true}) require.NoError(t, err) assert.Len(t, notifications, 1) assert.Equal(t, int64(27), notifications[0].Task.ID) @@ -46,7 +48,7 @@ func TestReminderGetTasksInTheNextMinute(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-12-02T01:13:00Z") require.NoError(t, err) - taskIDs, err := getTasksWithRemindersDueAndTheirUsers(s, now) + taskIDs, err := getTasksWithRemindersDueAndTheirUsers(s, now, builder.Eq{"users.email_reminders_enabled": true}) require.NoError(t, err) assert.Empty(t, taskIDs) }) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 6eb4f8746..a555b3cce 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -129,7 +129,8 @@ type Task struct { CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"` // Behaves exactly the same as with the TaskCollection.Expand parameter - Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand[]"` + Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"` + ExpandArr []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand[]"` // The position of the task - any task project can be sorted as usual by this parameter. // When accessing tasks via views with buckets, this is primarily used to sort them based on a range. @@ -215,7 +216,7 @@ type taskSearchOptions struct { // @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature." // @Param filter_timezone query string false "The time zone which should be used for date match (statements like "now" resolve to different actual times)" // @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." -// @Param expand query []string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." +// @Param expand query string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." // @Security JWTKeyAuth // @Success 200 {array} models.Task "The tasks" // @Failure 500 {object} models.Message "Internal error" @@ -1836,7 +1837,7 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) { // @Accept json // @Produce json // @Param id path int true "The task ID" -// @Param expand query []string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." +// @Param expand query string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. If set to `buckets`, the buckets of each task will be present in the response. If set to `reactions`, the reactions of each task will be present in the response. If set to `comments`, the first 50 comments of each task will be present in the response. You can set this multiple times with different values." // @Security JWTKeyAuth // @Success 200 {object} models.Task "The task" // @Failure 404 {object} models.Message "Task not found" @@ -1844,6 +1845,7 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) { // @Router /tasks/{id} [get] func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) { + t.Expand = append(t.Expand, t.ExpandArr...) expand := t.Expand *t, err = GetTaskByIDSimple(s, t.ID) if err != nil { diff --git a/pkg/models/tasks_permissions.go b/pkg/models/tasks_permissions.go index cfae002f1..bd3ef778f 100644 --- a/pkg/models/tasks_permissions.go +++ b/pkg/models/tasks_permissions.go @@ -40,6 +40,7 @@ func (t *Task) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { // CanRead determines if a user can read a task func (t *Task) CanRead(s *xorm.Session, a web.Auth) (canRead bool, maxPermission int, err error) { + t.Expand = append(t.Expand, t.ExpandArr...) expand := t.Expand // Get the task, error out if it doesn't exist *t, err = GetTaskByIDSimple(s, t.ID) diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index abf4b88c0..445cfd8c3 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -54,7 +54,7 @@ func TestTask_Create(t *testing.T) { assert.NotEmpty(t, task.UID) // Assert getting a new index assert.NotEmpty(t, task.Index) - assert.Equal(t, int64(33), task.Index) + assert.Equal(t, int64(34), task.Index) err = s.Commit() require.NoError(t, err) diff --git a/pkg/models/user_delete.go b/pkg/models/user_delete.go index 8ccd137e0..f36a5f2f9 100644 --- a/pkg/models/user_delete.go +++ b/pkg/models/user_delete.go @@ -169,14 +169,17 @@ func DeleteUser(s *xorm.Session, u *user.User) (err error) { } } - _, err = s.Where("id = ?", u.ID).Delete(&user.User{}) + // Notify before deleting the user row, because ShouldNotify will try to + // look up the user and fail if the row is already gone. + err = notifications.Notify(u, &user.AccountDeletedNotification{ + User: u, + }, s) if err != nil { return err } - return notifications.Notify(u, &user.AccountDeletedNotification{ - User: u, - }, s) + _, err = s.Where("id = ?", u.ID).Delete(&user.User{}) + return err } func ensureProjectAdminUser(s *xorm.Session, l *Project) (hadUsers bool, err error) { diff --git a/pkg/models/user_list_test.go b/pkg/models/user_list_test.go index cc2301dd5..616818b7c 100644 --- a/pkg/models/user_list_test.go +++ b/pkg/models/user_list_test.go @@ -58,7 +58,7 @@ func TestListUsers(t *testing.T) { all, err := user.ListAllUsers(s) require.NoError(t, err) - assert.Len(t, all, 16) + assert.Len(t, all, 17) }) t.Run("no search term", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -171,7 +171,7 @@ func TestListUsers(t *testing.T) { MatchFuzzily: true, }) require.NoError(t, err) - assert.Len(t, all, 16) + assert.Len(t, all, 17) }) // External team discoverability bypass tests diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 18494860d..f92795803 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -25,6 +25,7 @@ import ( "encoding/hex" "encoding/json" "io" + "net" "net/http" "net/url" "sort" @@ -39,6 +40,7 @@ import ( "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web" + "code.dny.dev/ssrf" "xorm.io/xorm" ) @@ -52,7 +54,9 @@ type Webhook struct { // The webhook events which should fire this webhook target Events []string `xorm:"JSON not null" valid:"required" json:"events"` // The project ID of the project this webhook target belongs to - ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint null index" json:"project_id" param:"project"` + // The user ID if this is a user-level webhook (mutually exclusive with ProjectID) + UserID int64 `xorm:"bigint null index" json:"user_id"` // If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing Secret string `xorm:"null" json:"secret"` // If provided, webhook requests will be sent with a Basic Auth header. @@ -78,10 +82,12 @@ func (w *Webhook) TableName() string { var availableWebhookEvents map[string]bool var availableWebhookEventsLock *sync.Mutex +var userDirectedWebhookEvents map[string]bool func init() { availableWebhookEvents = make(map[string]bool) availableWebhookEventsLock = &sync.Mutex{} + userDirectedWebhookEvents = make(map[string]bool) } func RegisterEventForWebhook(event events.Event) { @@ -105,6 +111,34 @@ func GetAvailableWebhookEvents() []string { return evts } +// RegisterUserDirectedEventForWebhook registers an event as both a webhook event and a user-directed event +func RegisterUserDirectedEventForWebhook(event events.Event) { + RegisterEventForWebhook(event) + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + userDirectedWebhookEvents[event.Name()] = true +} + +// IsUserDirectedEvent returns whether an event name is user-directed +func IsUserDirectedEvent(eventName string) bool { + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + return userDirectedWebhookEvents[eventName] +} + +// GetUserDirectedWebhookEvents returns a sorted list of user-directed webhook event names +func GetUserDirectedWebhookEvents() []string { + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + + evts := []string{} + for e := range userDirectedWebhookEvents { + evts = append(evts, e) + } + sort.Strings(evts) + return evts +} + // Create creates a webhook target // @Summary Create a webhook target // @Description Create a webhook target which receives POST requests about specified events from a project. @@ -120,6 +154,14 @@ func GetAvailableWebhookEvents() []string { // @Router /projects/{id}/webhooks [put] func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { + // Validate that exactly one of ProjectID or UserID is set + if w.ProjectID == 0 && w.UserID == 0 { + return InvalidFieldError([]string{"project_id", "user_id"}) + } + if w.ProjectID != 0 && w.UserID != 0 { + return InvalidFieldError([]string{"project_id", "user_id"}) + } + if !strings.HasPrefix(w.TargetURL, "http") { return InvalidFieldError([]string{"target_url"}) } @@ -128,6 +170,10 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { if _, has := availableWebhookEvents[event]; !has { return InvalidFieldError([]string{"events"}) } + // User-level webhooks can only subscribe to user-directed events + if w.UserID != 0 && !IsUserDirectedEvent(event) { + return InvalidFieldError([]string{"events"}) + } } w.CreatedByID = a.GetID() @@ -251,21 +297,30 @@ func getWebhookHTTPClient() (client *http.Client) { client = &http.Client{} client.Timeout = time.Duration(config.WebhooksTimeoutSeconds.GetInt()) * time.Second - if config.WebhooksProxyURL.GetString() == "" || config.WebhooksProxyPassword.GetString() == "" { - webhookClient = client - return + transport := &http.Transport{} + + // SSRF protection: block connections to non-globally-routable IPs unless + // explicitly allowed. Uses daenney/ssrf which validates resolved IPs + // against IANA Special Purpose Registries after DNS resolution, + // preventing DNS rebinding attacks. + if !config.WebhooksAllowNonRoutableIPs.GetBool() { + guardian := ssrf.New(ssrf.WithAnyPort()) + transport.DialContext = (&net.Dialer{ + Control: guardian.Safe, + }).DialContext } - proxyURL, _ := url.Parse(config.WebhooksProxyURL.GetString()) - - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - ProxyConnectHeader: http.Header{ + if config.WebhooksProxyURL.GetString() != "" && config.WebhooksProxyPassword.GetString() != "" { + proxyURL, _ := url.Parse(config.WebhooksProxyURL.GetString()) + transport.Proxy = http.ProxyURL(proxyURL) + transport.ProxyConnectHeader = http.Header{ "Proxy-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("vikunja:"+config.WebhooksProxyPassword.GetString()))}, "User-Agent": []string{"Vikunja/" + version.Version}, - }, + } } + client.Transport = transport + webhookClient = client return diff --git a/pkg/models/webhooks_permissions.go b/pkg/models/webhooks_permissions.go index efda80719..1f71dcd03 100644 --- a/pkg/models/webhooks_permissions.go +++ b/pkg/models/webhooks_permissions.go @@ -22,6 +22,12 @@ import ( ) func (w *Webhook) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + // User-level webhook: user owns it + if w.UserID > 0 { + return w.UserID == a.GetID(), int(PermissionRead), nil + } + + // Project-level webhook: delegate to project p := &Project{ID: w.ProjectID} return p.CanRead(s, a) } @@ -44,6 +50,26 @@ func (w *Webhook) canDoWebhook(s *xorm.Session, a web.Auth) (bool, error) { return false, nil } + // Load the webhook from DB to check ownership + if w.ID > 0 { + existing := &Webhook{ID: w.ID} + has, err := s.Get(existing) + if err != nil { + return false, err + } + if !has { + return false, nil + } + w.UserID = existing.UserID + w.ProjectID = existing.ProjectID + } + + // User-level webhook: user owns it or is creating new + if w.UserID > 0 || w.ProjectID == 0 { + return w.UserID == 0 || w.UserID == a.GetID(), nil + } + + // Project-level webhook: delegate to project p := &Project{ID: w.ProjectID} return p.CanUpdate(s, a) } diff --git a/pkg/models/webhooks_ssrf_test.go b/pkg/models/webhooks_ssrf_test.go new file mode 100644 index 000000000..fda2dca75 --- /dev/null +++ b/pkg/models/webhooks_ssrf_test.go @@ -0,0 +1,145 @@ +// 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 ( + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebhookSSRFProtection(t *testing.T) { + // Reset the singleton client before each test + resetWebhookClient := func() { + webhookClient = nil + } + + t.Run("blocks requests to loopback addresses", func(t *testing.T) { + resetWebhookClient() + config.WebhooksAllowNonRoutableIPs.Set(false) + config.WebhooksProxyURL.Set("") + config.WebhooksProxyPassword.Set("") + + w := &Webhook{ + ID: 1, + TargetURL: "http://127.0.0.1:12345/hook", + } + + err := w.sendWebhookPayload(&WebhookPayload{ + EventName: "test.event", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "prohibited") + }) + + t.Run("allows requests to public addresses", func(t *testing.T) { + resetWebhookClient() + config.WebhooksAllowNonRoutableIPs.Set(false) + config.WebhooksProxyURL.Set("") + config.WebhooksProxyPassword.Set("") + + // Start a test server (binds to 127.0.0.1 but we test + // separately that public IPs are allowed in principle) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + // When allownonroutableips is false, even our test server + // on 127.0.0.1 should be blocked. This confirms the guard works. + w := &Webhook{ + ID: 1, + TargetURL: ts.URL + "/hook", + } + + err := w.sendWebhookPayload(&WebhookPayload{ + EventName: "test.event", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "prohibited") + }) + + t.Run("allows loopback when allownonroutableips is true", func(t *testing.T) { + resetWebhookClient() + config.WebhooksAllowNonRoutableIPs.Set(true) + config.WebhooksProxyURL.Set("") + config.WebhooksProxyPassword.Set("") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + w := &Webhook{ + ID: 1, + TargetURL: ts.URL + "/hook", + } + + err := w.sendWebhookPayload(&WebhookPayload{ + EventName: "test.event", + }) + require.NoError(t, err) + }) + + t.Run("blocks requests to private RFC1918 addresses", func(t *testing.T) { + resetWebhookClient() + config.WebhooksAllowNonRoutableIPs.Set(false) + config.WebhooksProxyURL.Set("") + config.WebhooksProxyPassword.Set("") + + privateAddrs := []string{ + "http://10.0.0.1:80/hook", + "http://172.16.0.1:80/hook", + "http://192.168.1.1:80/hook", + } + + for _, addr := range privateAddrs { + webhookClient = nil // reset singleton for each + w := &Webhook{ + ID: 1, + TargetURL: addr, + } + + err := w.sendWebhookPayload(&WebhookPayload{ + EventName: "test.event", + }) + require.Error(t, err, "expected SSRF block for %s", addr) + } + }) + + t.Run("blocks requests to metadata endpoint", func(t *testing.T) { + resetWebhookClient() + config.WebhooksAllowNonRoutableIPs.Set(false) + config.WebhooksProxyURL.Set("") + config.WebhooksProxyPassword.Set("") + + w := &Webhook{ + ID: 1, + TargetURL: "http://169.254.169.254/latest/meta-data/", + } + + err := w.sendWebhookPayload(&WebhookPayload{ + EventName: "test.event", + }) + require.Error(t, err) + }) +} diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index 7049027cd..f7f007412 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -328,6 +328,32 @@ func checkProjectBackgroundRights(s *xorm.Session, c *echo.Context) (project *mo return } +func checkProjectBackgroundWritePermissions(s *xorm.Session, c *echo.Context) (project *models.Project, auth web.Auth, err error) { + auth, err = auth2.GetAuthFromClaims(c) + if err != nil { + return nil, auth, echo.NewHTTPError(http.StatusBadRequest, "Invalid auth token: "+err.Error()).Wrap(err) + } + + projectID, err := strconv.ParseInt(c.Param("project"), 10, 64) + if err != nil { + return nil, auth, echo.NewHTTPError(http.StatusBadRequest, "Invalid project ID: "+err.Error()).Wrap(err) + } + + project = &models.Project{ID: projectID} + can, err := project.CanUpdate(s, auth) + if err != nil { + _ = s.Rollback() + return nil, auth, err + } + if !can { + _ = s.Rollback() + log.Infof("Tried to modify project background of project %d while not having the permissions for it (User: %v)", projectID, auth) + return nil, auth, echo.NewHTTPError(http.StatusForbidden, "Forbidden") + } + + return +} + // GetProjectBackground serves a previously set background from a project // It has no knowledge of the provider that was responsible for setting the background. // @Summary Get the project background @@ -364,7 +390,7 @@ func GetProjectBackground(c *echo.Context) error { _ = s.Rollback() return err } - stat, err := bgFile.File.Stat() + stat, err := files.FileStat(bgFile) if err != nil { _ = s.Rollback() return err @@ -417,7 +443,7 @@ func RemoveProjectBackground(c *echo.Context) error { s := db.NewSession() defer s.Close() - project, auth, err := checkProjectBackgroundRights(s, c) + project, auth, err := checkProjectBackgroundWritePermissions(s, c) if err != nil { _ = s.Rollback() return err diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go index 481553f62..8c4ede991 100644 --- a/pkg/notifications/mail.go +++ b/pkg/notifications/mail.go @@ -26,16 +26,18 @@ import ( // Mail is a mail message type Mail struct { - from string - to string - subject string - actionText string - actionURL string - greeting string - introLines []*mailLine - outroLines []*mailLine - footerLines []*mailLine - threadID string + from string + to string + subject string + actionText string + actionURL string + greeting string + headerLine *mailLine + introLines []*mailLine + outroLines []*mailLine + footerLines []*mailLine + threadID string + conversational bool } type mailLine struct { @@ -101,12 +103,50 @@ func (m *Mail) HTML(line string) *Mail { return m.appendLine(line, true) } +// HeaderLine sets the header line for conversational emails (e.g., "@user mentioned you") +func (m *Mail) HeaderLine(line string) *Mail { + m.headerLine = &mailLine{Text: line, isHTML: true} + return m +} + // ThreadID sets the thread ID of the mail message for email threading func (m *Mail) ThreadID(threadID string) *Mail { m.threadID = threadID return m } +// Conversational sets the email to use conversational styling +func (m *Mail) Conversational() *Mail { + m.conversational = true + return m +} + +// IsConversational returns whether the email uses conversational styling +func (m *Mail) IsConversational() bool { + return m.conversational +} + +// CreateConversationalHeader creates a GitHub-style header line with avatar, action text, and task reference. +// The action string should already contain the doer's name (e.g. "alice left a comment"). +func CreateConversationalHeader(avatarDataURI, action, taskURL, projectTitle, taskIdentifier, taskTitle string) string { + avatarHTML := "" + if avatarDataURI != "" { + avatarHTML = fmt.Sprintf( + ``, + avatarDataURI, + ) + } + return fmt.Sprintf( + `%s%s (%s > %s) %s`, + avatarHTML, + action, + taskURL, + projectTitle, + taskTitle, + taskIdentifier, + ) +} + func (m *Mail) appendLine(line string, isHTML bool) *Mail { if m.actionURL == "" { m.introLines = append(m.introLines, &mailLine{ diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 7539aded5..67b254d35 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -20,6 +20,8 @@ import ( "bytes" "embed" templatehtml "html/template" + "regexp" + "strings" templatetext "text/template" "code.vikunja.io/api/pkg/config" @@ -45,11 +47,25 @@ const mailTemplatePlain = ` {{ $line.Text }} {{ end }}` +const mailTemplateConversationalPlain = ` +{{ if .HeaderLinePlain }}{{ .HeaderLinePlain }} +{{ end }}{{ range $line := .IntroLines}} +{{ $line.Text }} +{{ end }} +{{ if .ActionURL }}{{ .ActionText }}: +{{ .ActionURL }}{{end}} +{{ range $line := .OutroLines}} +{{ $line.Text }} +{{ end }} +{{ range $line := .FooterLines}} +{{ $line.Text }} +{{ end }}` + const mailTemplateHTML = ` - +