Merge branch 'main' into feat/bucket-select-task-detail

This commit is contained in:
Lars de Ridder 2026-03-23 12:06:42 +01:00 committed by GitHub
commit 2da4f97bf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
181 changed files with 7810 additions and 2595 deletions

View File

@ -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?

View File

@ -5,6 +5,7 @@ env:
on:
pull_request:
merge_group:
push:
tags:
- v*

View File

@ -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

View File

@ -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$

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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."
}
]
},

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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": {

View File

@ -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"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,6 @@
v-if="project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
class="menu-list-dropdown"
:project="project"
:simple="true"
>
<template #trigger="{toggleOpen}">
<BaseButton

View File

@ -82,7 +82,6 @@ const DEFAULT_COLORS = [
]
const color = ref('')
const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
@ -112,13 +111,7 @@ function update(force = false) {
return
}
if (lastChangeTimeout.value !== null) {
clearTimeout(lastChangeTimeout.value)
}
lastChangeTimeout.value = setTimeout(() => {
model.value = color.value
}, 500)
model.value = color.value
}
function reset() {

View File

@ -68,9 +68,11 @@ defineSlots<{
const initialMount = ref(false)
const open = ref(false)
const dropdown = ref<HTMLElement>()
const dropdownMenu = ref<HTMLElement>()
const dropdownPosition = ref({x: 0, y: 0})
const dropdownMenuOffset = computed(() => 4)
function close() {
open.value = false
@ -87,7 +89,7 @@ async function updatePosition() {
placement: 'bottom-end',
strategy: 'absolute',
middleware: [
offset(4),
offset(dropdownMenuOffset.value),
autoPlacement({
allowedPlacements: ['bottom-end', 'top-end', 'bottom-start', 'top-start'],
padding: 8,
@ -102,6 +104,7 @@ async function updatePosition() {
const dropdownMenuStyle = computed(() => ({
left: `${dropdownPosition.value.x}px`,
top: `${dropdownPosition.value.y}px`,
'--hover-offset': `${dropdownMenuOffset.value}px`,
}))
function toggleOpen() {
@ -129,6 +132,12 @@ onClickOutside(dropdown, (e) => {
position: relative;
}
.dropdown-menu::before {
content: "";
position: absolute;
inset: calc(var(--hover-offset) * -1);
}
.dropdown-menu {
min-inline-size: 12rem;
position: absolute;

View File

@ -0,0 +1,277 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import type {IWebhook} from '@/modelTypes/IWebhook'
import WebhookModel from '@/models/webhook'
import BaseButton from '@/components/base/BaseButton.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FormField from '@/components/input/FormField.vue'
import Expandable from '@/components/base/Expandable.vue'
import User from '@/components/misc/User.vue'
import {formatDateShort} from '@/helpers/time/formatDate'
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
const props = defineProps<{
webhooks: IWebhook[]
availableEvents: string[]
loading?: boolean
}>()
const emit = defineEmits<{
create: [webhook: IWebhook]
delete: [webhookId: number]
}>()
defineOptions({name: 'WebhookManager'})
const showNewForm = ref(false)
const showBasicAuth = ref(false)
const newWebhook = ref(new WebhookModel())
const newWebhookEvents = ref<Record<string, boolean>>({})
function initEvents(events: string[]) {
newWebhookEvents.value = Object.fromEntries(
events.map(event => [event, false]),
)
}
watch(() => props.availableEvents, (events) => {
if (events) initEvents(events)
}, {immediate: true})
const webhookTargetUrlValid = ref(true)
const selectedEventsValid = ref(true)
const showDeleteModal = ref(false)
const webhookIdToDelete = ref<number>()
function validateTargetUrl() {
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
}
function getSelectedEventsArray() {
return Object.entries(newWebhookEvents.value)
.filter(([, use]) => use)
.map(([event]) => event)
}
function validateSelectedEvents() {
const events = getSelectedEventsArray()
selectedEventsValid.value = events.length > 0
}
function create() {
validateTargetUrl()
if (!webhookTargetUrlValid.value) {
return
}
const selectedEvents = getSelectedEventsArray()
newWebhook.value.events = selectedEvents
validateSelectedEvents()
if (!selectedEventsValid.value) {
return
}
emit('create', newWebhook.value)
newWebhook.value = new WebhookModel()
initEvents(props.availableEvents)
showNewForm.value = false
}
function confirmDelete(webhookId: number) {
webhookIdToDelete.value = webhookId
showDeleteModal.value = true
}
function doDelete() {
if (webhookIdToDelete.value) {
emit('delete', webhookIdToDelete.value)
}
showDeleteModal.value = false
}
</script>
<template>
<div>
<XButton
v-if="!(webhooks?.length === 0 || showNewForm)"
icon="plus"
class="mbe-4"
@click="showNewForm = true"
>
{{ $t('project.webhooks.create') }}
</XButton>
<div
v-if="webhooks?.length === 0 || showNewForm"
class="p-4"
>
<FormField
id="targetUrl"
v-model="newWebhook.targetUrl"
:label="$t('project.webhooks.targetUrl')"
required
:placeholder="$t('project.webhooks.targetUrl')"
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
@focusout="validateTargetUrl"
/>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
<BaseButton
class="mbe-2 has-text-primary"
@click="showBasicAuth = !showBasicAuth"
>
{{ $t('project.webhooks.basicauthlink') }}
</BaseButton>
<Expandable
:open="showBasicAuth"
class="content"
>
<div class="field">
<label
class="label"
for="basicauthuser"
>
{{ $t('project.webhooks.basicauthuser') }}
</label>
<div class="control">
<input
id="basicauthuser"
v-model="newWebhook.basicauthuser"
class="input"
>
</div>
</div>
<div class="field">
<label
class="label"
for="basicauthpassword"
>
{{ $t('project.webhooks.basicauthpassword') }}
</label>
<div class="control">
<input
id="basicauthpassword"
v-model="newWebhook.basicauthpassword"
class="input"
>
</div>
</div>
</Expandable>
<div class="field">
<label
class="label"
for="events"
>
{{ $t('project.webhooks.events') }}
</label>
<p class="help">
{{ $t('project.webhooks.eventsHint') }}
</p>
<div class="control">
<FancyCheckbox
v-for="event in availableEvents"
:key="event"
v-model="newWebhookEvents[event]"
class="available-events-check"
@update:modelValue="validateSelectedEvents"
>
{{ event }}
</FancyCheckbox>
</div>
<p
v-if="!selectedEventsValid"
class="help is-danger"
>
{{ $t('project.webhooks.mustSelectEvents') }}
</p>
</div>
<XButton
icon="plus"
@click="create"
>
{{ $t('project.webhooks.create') }}
</XButton>
</div>
<table
v-if="webhooks?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th>{{ $t('project.webhooks.targetUrl') }}</th>
<th>{{ $t('project.webhooks.events') }}</th>
<th>{{ $t('misc.created') }}</th>
<th>{{ $t('misc.createdBy') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<td>{{ w.targetUrl }}</td>
<td>{{ w.events.join(', ') }}</td>
<td>{{ formatDateShort(w.created) }}</td>
<td>
<User
:avatar-size="25"
:user="w.createdBy"
/>
</td>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="() => confirmDelete(w.id)"
/>
</td>
</tr>
</tbody>
</table>
<Modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="doDelete()"
>
<template #header>
<span>{{ $t('project.webhooks.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.webhooks.deleteText') }}</p>
</template>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.available-events-check {
margin-inline-end: .5rem;
inline-size: 12.5rem;
}
</style>

View File

@ -56,14 +56,13 @@
{{ $t('menu.edit') }}
</DropdownItem>
<DropdownItem
v-if="!simple"
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
icon="eye"
>
{{ $t('menu.views') }}
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled && !simple"
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
icon="image"
>
@ -82,7 +81,6 @@
{{ $t('menu.duplicate') }}
</DropdownItem>
<DropdownItem
v-if="!simple"
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
@ -141,12 +139,9 @@ import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {PERMISSIONS} from '@/constants/permissions'
const props = withDefaults(defineProps<{
const props = defineProps<{
project: IProject
simple?: boolean
}>(), {
simple: false,
})
}>()
const projectStore = useProjectStore()
const subscription = ref<ISubscription | null>(null)

View File

@ -11,12 +11,48 @@
</h1>
<div
ref="switchViewContainerRef"
class="switch-view-container d-print-none"
:class="{'is-justify-content-flex-end': views.length === 1}"
>
<!-- Dropdown mode when buttons overflow -->
<Dropdown
v-if="isOverflowing && views.length > 1"
class="switch-view-dropdown"
>
<template #trigger="{ toggleOpen }">
<BaseButton
class="switch-view switch-view-dropdown-trigger"
@click="toggleOpen"
>
{{ activeViewTitle }}
<Icon
icon="chevron-down"
class="dropdown-icon"
/>
</BaseButton>
</template>
<template #default="{ close }">
<div @click="close">
<DropdownItem
v-for="view in views"
:key="view.id"
:to="getViewRoute(view)"
:class="{'is-active': view.id === viewId}"
>
{{ getViewTitle(view) }}
</DropdownItem>
</div>
</template>
</Dropdown>
<!-- Inline buttons, hidden when overflowing but kept in DOM for width measurement -->
<div
v-if="views.length > 1"
ref="switchViewRef"
class="switch-view"
:class="{'switch-view--hidden': isOverflowing || !overflowChecked}"
:aria-hidden="isOverflowing || undefined"
>
<BaseButton
v-for="view in views"
@ -24,6 +60,7 @@
class="switch-view-button"
:class="{'is-active': view.id === viewId}"
:to="getViewRoute(view)"
:tabindex="isOverflowing ? -1 : undefined"
>
{{ getViewTitle(view) }}
</BaseButton>
@ -45,10 +82,14 @@
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {computed, ref, watch, nextTick, onMounted} from 'vue'
import {useResizeObserver} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import Icon from '@/components/misc/Icon'
import Message from '@/components/misc/Message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
@ -74,6 +115,29 @@ const baseStore = useBaseStore()
const projectStore = useProjectStore()
const viewFiltersStore = useViewFiltersStore()
const switchViewContainerRef = ref<HTMLElement>()
const switchViewRef = ref<HTMLElement>()
const isOverflowing = ref(false)
const overflowChecked = ref(false)
function checkOverflow() {
if (!switchViewRef.value || !switchViewContainerRef.value) {
return
}
const buttonsWidth = switchViewRef.value.scrollWidth
const containerWidth = switchViewContainerRef.value.clientWidth
isOverflowing.value = buttonsWidth > containerWidth
overflowChecked.value = true
}
onMounted(() => {
checkOverflow()
})
useResizeObserver(switchViewContainerRef, () => {
requestAnimationFrame(() => checkOverflow())
})
const currentProject = computed<IProject>(() => {
return baseStore.currentProject || {
id: 0,
@ -86,6 +150,16 @@ useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value)
const views = computed(() => projectStore.projects[props.projectId]?.views)
const activeViewTitle = computed(() => {
const activeView = views.value?.find((v: IProjectView) => v.id === props.viewId)
return activeView ? getViewTitle(activeView) : ''
})
// Re-check overflow when views change
watch(views, () => {
nextTick(() => checkOverflow())
})
function getViewTitle(view: IProjectView) {
switch (view.title) {
case 'List':
@ -113,6 +187,7 @@ function getViewRoute(view: IProjectView) {
<style lang="scss" scoped>
.switch-view-container {
position: relative;
min-block-size: $switch-view-height;
margin-block-end: 1rem;
@ -136,9 +211,34 @@ function getViewRoute(view: IProjectView) {
padding: .5rem;
}
.switch-view--hidden {
position: absolute;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
inset-inline-start: 0;
inset-inline-end: 0;
overflow: hidden;
}
.switch-view-dropdown-trigger {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: .25rem;
font-weight: bold;
color: var(--switch-view-color);
background: var(--primary);
}
.dropdown-icon {
font-size: .6rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
white-space: nowrap;
border-radius: $radius;
transition: all 100ms;

View File

@ -184,7 +184,6 @@ import {canPreview} from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
import {useAttachmentStore} from '@/stores/attachments'
import {formatDisplayDate, formatDateLong} from '@/helpers/time/formatDate'
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize'
@ -201,9 +200,9 @@ const props = withDefaults(defineProps<{
editEnabled: true,
})
// FIXME: this should go through the store
const emit = defineEmits<{
'taskChanged': [ITask],
'update:attachments': [IAttachment[]],
}>()
const EDITOR_SELECTOR = '.tiptap, .tiptap__editor, [contenteditable]'
@ -232,8 +231,7 @@ const {t} = useI18n({useScope: 'global'})
const attachmentService = shallowReactive(new AttachmentService())
const attachmentStore = useAttachmentStore()
const attachments = computed(() => attachmentStore.attachments)
const attachments = computed(() => props.task.attachments ?? [])
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
@ -335,7 +333,10 @@ function uploadNewAttachment() {
async function uploadFilesToTask(files: File[] | FileList) {
try {
await uploadFiles(attachmentService, props.task.id, files)
const uploaded = await uploadFiles(attachmentService, props.task.id, files)
if (uploaded.length > 0) {
emit('update:attachments', [...attachments.value, ...uploaded])
}
} catch (e) {
error(e)
}
@ -354,7 +355,8 @@ async function deleteAttachment() {
try {
const r = await attachmentService.delete(attachmentToDelete.value)
attachmentStore.removeById(attachmentToDelete.value.id)
const updated = attachments.value.filter(a => a.id !== attachmentToDelete.value!.id)
emit('update:attachments', updated)
success(r)
setAttachmentToDelete(null)
} catch (e) {

View File

@ -2,9 +2,8 @@ import AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import AttachmentService from '@/services/attachment'
import {useTaskStore} from '@/stores/tasks'
export async function uploadFile(taskId: number, file: File, onSuccess?: (url: string) => void) {
export async function uploadFile(taskId: number, file: File, onSuccess?: (url: string) => void): Promise<IAttachment[]> {
const attachmentService = new AttachmentService()
const files = [file]
@ -16,16 +15,14 @@ export async function uploadFiles(
taskId: number,
files: File[] | FileList,
onSuccess?: (attachmentUrl: string) => void,
) {
): Promise<IAttachment[]> {
const attachmentModel = new AttachmentModel({taskId})
const response = await attachmentService.create(attachmentModel, files)
console.debug(`Uploaded attachments for task ${taskId}, response was`, response)
const uploaded: IAttachment[] = []
response.success?.map((attachment: IAttachment) => {
useTaskStore().addTaskAttachment({
taskId,
attachment,
})
uploaded.push(attachment)
onSuccess?.(generateAttachmentUrl(taskId, attachment.id))
})
@ -33,6 +30,8 @@ export async function uploadFiles(
const messages = response.errors.map((e: {message: string}) => e.message)
throw new Error(messages.join('\n'))
}
return uploaded
}
export function generateAttachmentUrl(taskId: number, attachmentId: number) {

View File

@ -112,7 +112,6 @@
"setupSuccess": "Успешно настроихте двуфакторното удостоверяване!",
"enterPassword": "Моля, въведете вашата парола",
"disable": "Изключване на двуфакторното удостоверяване",
"confirmSuccess": "Успешно потвърдихте вашата TOTP настройка и можете да я използвате от сега нататък!",
"disableSuccess": "Двуфакторното удостоверяване беше успешно изключено."
},
"caldav": {

View File

@ -43,6 +43,7 @@
"forgotPassword": "Zapomenuté heslo?",
"resetPassword": "Obnovit heslo",
"resetPasswordAction": "Poslat odkaz na obnovení hesla",
"resetPasswordSuccess": "Zkontrolujte doručenou poštu! Měli byste obdržet email s pokyny, jak obnovit své heslo.",
"passwordsDontMatch": "Hesla se neshodují",
"confirmEmailSuccess": "Úspěšně jste potvrdili svůj e-mail! Nyní se můžete přihlásit.",
"totpTitle": "Kód dvoufaktorového ověření",
@ -60,43 +61,88 @@
"usernameMustNotLookLikeUrl": "Uživatelské jméno nesmí vypadat jako adresa URL.",
"passwordRequired": "Zadejte prosím heslo.",
"passwordNotMin": "Heslo musí mít nejméně 8 znaků.",
"passwordNotMax": "Heslo může mít maximálně 72 znaků.",
"showPassword": "Ukázat heslo",
"hidePassword": "Skrýt heslo",
"noAccountYet": "Ještě nemáte účet?",
"alreadyHaveAnAccount": "Už máte svůj účet?",
"remember": "Zůstat trvale přihlášen"
"remember": "Zůstat trvale přihlášen",
"registrationDisabled": "Registrace je vypnuta.",
"passwordResetTokenMissing": "Chybí token pro obnovení hesla.",
"registrationFailed": "Při registraci došlo k chybě. Zkontrolujte prosím zadané údaje a zkuste to znovu."
},
"settings": {
"title": "Nastavení",
"newPasswordTitle": "Aktualizujte své heslo",
"newPassword": "Nové heslo",
"newPasswordConfirm": "Potvrzení nového hesla",
"currentPassword": "Stávající heslo",
"currentPasswordPlaceholder": "Vaše současné heslo",
"passwordsDontMatch": "Nové heslo se neshoduje s potvrzením hesla.",
"passwordUpdateSuccess": "Heslo bylo úspěšně změněno.",
"updateEmailTitle": "Aktualizujte svou e-mailovou adresu",
"updateEmailNew": "Nová emailová adresa",
"updateEmailSuccess": "Vaše e-mailová adresa byla úspěšně aktualizována. Poslali jsme vám odkaz pro její potvrzení.",
"general": {
"title": "Obecná nastavení",
"name": "Moje jméno",
"newName": "Nové jméno",
"savedSuccess": "Nastavení bylo úspěšně aktualizováno.",
"emailReminders": "Posílat mi připomenutí pro úkoly emailem",
"overdueReminders": "Pošlete mi každý den shrnutí mých zpožděných úkolů",
"discoverableByName": "Umožnit ostatním uživatelům přidat mě jako člena do týmů nebo projektů, když hledají mé jméno",
"discoverableByEmail": "Umožnit ostatním uživatelům, aby mě přidali jako člena do týmů nebo projektů, když hledají můj úplný e-mail",
"playSoundWhenDone": "Přehrát zvuk při označení úkolů jako hotovo",
"allowIconChanges": "Zobrazit speciální loga v určitých časech",
"alwaysShowBucketTaskCount": "Vždy zobrazit počet úkolů ve sloupcích Kanbanu",
"defaultTaskRelationType": "Výchozí typ vztahu úkolů",
"weekStart": "První den týdne",
"weekStartSunday": "Neděle",
"weekStartMonday": "Pondělí",
"language": "Jazyk",
"defaultProject": "Výchozí projekt",
"defaultView": "Výchozí zobrazení",
"timezone": "Časové pásmo",
"overdueTasksRemindersTime": "Čas odeslání emailu o zpožděných úkolech",
"filterUsedOnOverview": "Uložený filtr použitý na stránce přehledu"
"filterUsedOnOverview": "Uložený filtr použitý na stránce přehledu",
"minimumPriority": "Minimální viditelná priorita úkolu",
"dateDisplay": "Formát zobrazení data",
"dateDisplayOptions": {
"relative": "Relativní (např. před 3 dny)",
"mm-dd-yyyy": "MM-DD-RRRR",
"dd-mm-yyyy": "DD-MM-RRRR",
"yyyy-mm-dd": "RRRR-MM-DD",
"mm/dd/yyyy": "MM/DD/RRRR",
"dd/mm/yyyy": "DD/MM/RRRR",
"yyyy/mm/dd": "RRRR/MM/DD"
},
"timeFormat": "Formát času",
"timeFormatOptions": {
"12h": "12 hodinový (AM/PM)",
"24h": "24 hodinový (HH:mm)"
},
"externalUserNameChange": "Vaše jméno je spravováno vaším poskytovatelem přihlášení ({provider}). Chcete-li ho změnit, aktualizujte ho tam."
},
"sections": {
"personalInformation": "Osobní údaje",
"taskAndNotifications": "Projekty a úkoly",
"privacy": "Ochrana soukromí",
"localization": "Lokalizace",
"appearance": "Vzhled a chování"
},
"totp": {
"title": "Dvoufaktorové ověření",
"enroll": "Zápis",
"finishSetupPart1": "Chcete-li dokončit nastavení, použijte tento tajný klíč ve vaší TOTP aplikaci (Google Authenticator nebo podobné):",
"finishSetupPart2": "Poté zadejte kód z vaší aplikace.",
"scanQR": "Případně můžete naskenovat tento QR kód:",
"passcode": "Přístupový kód",
"passcodePlaceholder": "Kód vygenerovaný vaší TOTP aplikací",
"confirmNotice": "Po povolení dvoufázového ověřování budete odhlášeni ze všech relací a musíte se znovu přihlásit.",
"setupSuccess": "Úspěšně jste nastavili dvoufaktorové ověření!",
"enterPassword": "Zadejte prosím heslo",
"disable": "Zakázat dvoufaktorové ověření",
"confirmSuccess": "Úspěšně jste povolili dvoufázové ověřování!",
"disableSuccess": "Dvoufázové ověření bylo úspěšně vypnuto."
},
"caldav": {
@ -119,7 +165,9 @@
"upload": "Nahrát",
"uploadAvatar": "Nahrát avatara",
"statusUpdateSuccess": "Stav avatara byl úspěšně aktualizován!",
"setSuccess": "Avatar byl úspěšně nastaven!"
"setSuccess": "Avatar byl úspěšně nastaven!",
"ldap": "Váš avatar je automaticky synchronizován s adresářovou službou vaší organizace (LDAP). Informujte se u vašeho IT týmu o tom, jak jej změnit.",
"openid": "Váš avatar je automaticky synchronizován s poskytovatelem přihlášení ({provider}). Chcete-li ho změnit, aktualizujte ho tam."
},
"quickAddMagic": {
"title": "Kouzelný režim pro rychlé přidání",
@ -136,6 +184,13 @@
"dark": "Tmavý"
}
},
"backgroundBrightness": {
"title": "Jas pozadí"
},
"webhooks": {
"title": "Webhook oznámení",
"description": "Nastavte URL webhooků, který přijme POST požadavek při překročení termínu nebo připomenutí. Tyto webhooky přijímají události ze všech vašci projektů."
},
"apiTokens": {
"title": "API Tokeny",
"general": "API tokeny umožňují používat Vikunja API bez uživatelských údajů.",
@ -147,6 +202,7 @@
"90d": "90 dní",
"permissionExplanation": "Oprávnění vám umožní nastavit, k čemu lze api token použít.",
"titleRequired": "Je vyžadován název",
"permissionRequired": "Vyberte prosím alespoň jedno oprávnění ze seznamu.",
"expired": "Platnost tohoto tokenu vypršela {ago}.",
"tokenCreatedSuccess": "Zde je tvůj nový api token: {token}",
"tokenCreatedNotSeeAgain": "Ulož jej na zabezpečeném místě, už ho znovu neuvidíš!",
@ -162,6 +218,20 @@
"expiresAt": "Vyprší v",
"permissions": "Oprávnění"
}
},
"sessions": {
"title": "Relace",
"description": "Toto jsou všechna zařízení aktuálně přihlášená k vašemu účtu. Můžete zrušit jakoukoli relaci a odhlásit zařízení. Ukončení platnosti může trvat až 10 minut.",
"deviceInfo": "Zařízení",
"ipAddress": "IP adresa",
"lastActive": "Poslední aktivita",
"current": "Tato relace",
"delete": {
"header": "Zrušit relaci",
"text": "Opravdu chcete zrušit tuto relaci? Zařízení bude odhlášeno. Úplné vypršení platnosti relace může trvat až 10 minut."
},
"deleteSuccess": "Relace byla úspěšně zrušena. Úplné vypršení platnosti relace může trvat až 10 minut.",
"noOtherSessions": "Žádné další aktivní relace."
}
},
"deletion": {
@ -186,13 +256,16 @@
"descriptionPasswordRequired": "Pokračujte zadáním vašeho hesla:",
"request": "Požádat o kopii mých dat",
"success": "Úspěšně jste požádali o svá data! Jakmile budou připravena ke stažení, pošleme Vám e-mail.",
"downloadTitle": "Stáhnout exportovaná Vikunja data"
"downloadTitle": "Stáhnout exportovaná Vikunja data",
"ready": "Váš export je připraven ke stažení. Můžete jej stáhnout do {0}.",
"requestNew": "Požádat o další export"
}
},
"project": {
"archivedMessage": "Tento projekt je archivován. Není možné vytvořit ani upravovat jeho úkoly.",
"archived": "Archivováno",
"showArchived": "Zobrazit archivované",
"title": "Název",
"color": "Barva",
"projects": "Projekty",
"parent": "Nadřazený projekt",
@ -201,6 +274,11 @@
"shared": "Sdílené projekty",
"noDescriptionAvailable": "Není k dispozici žádný popis projektu.",
"inboxTitle": "Inbox",
"favorite": "Označit tento projekt jako oblíbený",
"unfavorite": "Odstranit tento projekt z oblíbených",
"openSettingsMenu": "Otevřít nastavení projektu",
"description": "Popis projektu",
"favoriteDescription": "Tento projekt má všechny úkoly označené jako oblíbené.",
"create": {
"header": "Nový projekt",
"titlePlaceholder": "Název projektu přijde sem…",
@ -288,11 +366,20 @@
"addedSuccess": "{type} byl úspěšně přidán.",
"updatedSuccess": "{type} byl úspěšně přidán."
},
"permission": {
"title": "Oprávnění",
"read": "Pouze pro čtení",
"readWrite": "Čtení a zápis",
"admin": "Administrátor"
},
"attributes": {
"link": "Odkaz",
"delete": "Smazat"
}
},
"first": {
"title": "První pohled"
},
"list": {
"title": "Seznam",
"add": "Přidat",
@ -308,7 +395,27 @@
"month": "Měsíc",
"day": "Den",
"hour": "Hodina",
"range": "Časové období"
"range": "Časové období",
"chartLabel": "Projektový Ganttův diagram",
"taskBarsForRow": "Chlívky pro řádek {rowId}",
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
"scheduledDates": "Plánovaná data",
"estimatedDates": "Předpokládaná data",
"resizeStartDate": "Změňte počáteční datum pro {task}",
"resizeEndDate": "Změňte koncové datum pro {task}",
"timelineHeader": "Hlavička časového plánu s měsíci a dny",
"monthsRow": "Řádek měsíců",
"daysRow": "Řádek dnů",
"monthLabel": "Měsíc: {month}",
"dayLabel": "Den: {date}, {weekday}",
"dayLabelToday": "Dnes: {date}, {weekday}",
"taskAriaLabel": "Úkol: {task}",
"taskAriaLabelById": "Úkol {id}",
"partialDatesStart": "Pouze datum zahájení (otevřený konec)",
"partialDatesEnd": "Pouze datum konce (otevřený konec)",
"expandGroup": "Rozbalit skupinu: {task}",
"collapseGroup": "Sbalit skupinu: {task}",
"toggleRelationArrows": "Přepnout šipky relací"
},
"table": {
"title": "Tabulka",
@ -337,7 +444,8 @@
"deleteBucketSuccess": "Sloupec byl úspěšně smazán.",
"bucketTitleSavedSuccess": "Název sloupce byl úspěšně uložen.",
"bucketLimitSavedSuccess": "Limit sloupce byl úspěšně uložen.",
"collapse": "Sbalit tento sloupec"
"collapse": "Sbalit tento sloupec",
"bucketLimitReached": "Dosáhli jste limitu sloupce. Odstraňte úkoly nebo zvýšte limit pro přidání nových úkolů."
},
"pseudo": {
"favorites": {
@ -349,12 +457,16 @@
"targetUrl": "Cílová URL",
"targetUrlInvalid": "Zadejte prosím platnou URL adresu.",
"events": "Události",
"eventsHint": "Vyberte všechny události, pro které by tento webhook měl dostávat aktualizace (v rámci aktuálního projektu).",
"mustSelectEvents": "Musíte zvolit alespoň jednu událost.",
"delete": "Smazat tento webhook",
"deleteText": "Opravdu chcete odstranit tento webhook? Externí cíle již nebudou informovány o jeho událostech.",
"deleteSuccess": "Webhook byl úspěšně odstraněn.",
"create": "Vytvořit webhook",
"secret": "Tajný klíč",
"basicauthuser": "Uživatel pro Basic Auth",
"basicauthpassword": "Heslo pro Basic Auth",
"basicauthlink": "Použít Basic Auth?",
"secretHint": "Pokud je zadáno, všechny požadavky na cílovou adresu URL webhooku budou podepsány pomocí HMAC.",
"secretDocs": "Další podrobnosti o používání tajných klíčů naleznete v dokumentaci."
},
@ -372,13 +484,18 @@
"titleRequired": "Zadejte prosím název.",
"delete": "Odstranit tento pohled",
"deleteText": "Jste si jisti, že chcete odstranit tento pohled? Nebude již možné jej použít k zobrazení úkolů v tomto projektu. Tato akce neodstraní žádné úkoly. Toto nelze vrátit zpět!",
"onlyAdminsCanEdit": "Pohledy mohou upravovat pouze správci projektu."
"deleteSuccess": "Pohled byl úspěšně odstraněn.",
"onlyAdminsCanEdit": "Pohledy mohou upravovat pouze správci projektu.",
"updateSuccess": "Pohled byl úspěšně upraven."
}
},
"filters": {
"title": "Filtry",
"clear": "Vymazat filtry",
"showResults": "Zobrazit výsledky",
"noResults": "Žádné výsledky",
"fromView": "Aktuální pohled má také nastavený filtr:",
"fromViewBoth": "Bude použito v kombinaci s tím, co zde zadáte.",
"attributes": {
"title": "Název",
"titlePlaceholder": "Název uloženého filtru přijde sem…",
@ -416,6 +533,7 @@
"help": {
"intro": "K filtrování úkolů můžete použít syntaxi dotazů podobnou SQL. Dostupná pole pro filtrování zahrnují:",
"link": "Jak to funguje?",
"canUseDatemath": "Můžete použít matematiku a nastavit relativní data. Pro více informací klikněte na datum v dotazu.",
"fields": {
"done": "Zda je úkol dokončen nebo ne",
"priority": "Úroveň priority úkolu (1-5)",
@ -426,7 +544,10 @@
"doneAt": "Datum a čas, kdy byl úkol dokončen",
"assignees": "Přiřazení uživatelé",
"labels": "Štítky přiřazené úkolu",
"project": "Projekt, do kterého úkol patří (k dispozici pouze pro uložené filtry, ne na úrovni projektu)"
"project": "Projekt, do kterého úkol patří (k dispozici pouze pro uložené filtry, ne na úrovni projektu)",
"reminders": "Připomenutí úlohy jako pole s datem, vrátí všechny úkoly s alespoň jednou upomínkou odpovídající dotazu",
"created": "Čas a datum, kdy byl úkol vytvořen",
"updated": "Čas a datum, kdy byl úkol naposledy změněn"
},
"operators": {
"intro": "Dostupné operátory pro filtrování zahrnují:",
@ -436,7 +557,9 @@
"greaterThanOrEqual": "Větší nebo rovno než",
"lessThan": "Menší než",
"lessThanOrEqual": "Menší nebo rovno než",
"like": "Odpovídá vzoru (s použitím zástupného znaku %)"
"like": "Odpovídá vzoru (s použitím zástupného znaku %)",
"in": "Odpovídá libovolné hodnotě v seznamu hodnot oddělených čárkou",
"notIn": "Odpovídá libovolné hodnotě nepřítomné v seznamu hodnot oddělených čárkou"
},
"logicalOperators": {
"intro": "Pro kombinování více podmínek můžete použít tyto logické operátory:",
@ -502,7 +625,11 @@
"authenticating": "Ověřování…",
"passwordRequired": "Tento sdílený projekt vyžaduje heslo. Zadejte jej níže:",
"error": "Došlo k chybě.",
"invalidPassword": "Neplatné heslo."
"invalidPassword": "Neplatné heslo.",
"accessDenied": "Přístup byl odepřen. Zkontrolujte svá oprávnění a zkuste to znovu.",
"serverError": "Došlo k chybě serveru. Opakujte akci později.",
"projectLoadError": "Nepodařilo se načíst informace o projektu.",
"retry": "Opakovat"
},
"navigation": {
"overview": "Přehled",
@ -543,7 +670,8 @@
"created": "Vytvořeno",
"createdBy": "Vytvořil(a) {0}",
"actions": "Akce",
"cannotBeUndone": "Toto nelze vrátit!"
"cannotBeUndone": "Toto nelze vrátit!",
"avatarOfUser": "Profilový obrázek {user}"
},
"input": {
"resetColor": "Obnovit barvu",
@ -700,6 +828,7 @@
"addReminder": "Přidat připomínku…",
"doneSuccess": "Úkol byl úspěšně označen jako dokončený.",
"undoneSuccess": "Úkol byl úspěšně znovu otevřen.",
"movedToProject": "Úkol byl přesunut do {project}.",
"undo": "Vrátit zpět",
"openDetail": "Otevřít zobrazení detailu úkolu",
"checklistTotal": "{checked} z {total} úkolů",
@ -707,10 +836,13 @@
"show": {
"titleCurrent": "Aktuální úkoly",
"titleDates": "Úkoly od {from} do {to}",
"noDates": "Zobrazit úkoly bez datumu",
"overdue": "Zobrazit zpožděné úkoly",
"fromuntil": "Úkoly od {from} do {until}",
"select": "Vyberte rozsah dat",
"noTasks": "Nic na práci - užij si pěkný den!"
"noTasks": "Nic na práci - užij si pěkný den!",
"filterByLabel": "Filtrování podle štítku {label}",
"clearLabelFilter": "Vymazat filtr štítků"
},
"detail": {
"chooseDueDate": "Klikněte zde pro nastavení termínu dokončení",
@ -724,9 +856,12 @@
"doneAt": "Dokončeno {0}",
"updateSuccess": "Úkol byl úspěšně uložen.",
"deleteSuccess": "Úkol byl úspěšně smazán.",
"duplicateSuccess": "Úkol byl úspěšně duplikován.",
"belongsToProject": "Tento úkol patří do projektu '{project}'",
"back": "Zpět k projektu",
"due": "Termín {at}",
"closePopup": "Zavřít vyskakovací okno",
"scrollToBottom": "Přejít na konec",
"organization": "Organizace",
"management": "Management",
"dateAndTime": "Datum a čas",
@ -748,6 +883,7 @@
"attachments": "Přidat přílohy",
"relatedTasks": "Přidat vztah",
"moveProject": "Přesunout",
"duplicate": "Duplikovat",
"color": "Nastavit barvu",
"delete": "Smazat",
"favorite": "Přidat do oblíbených",
@ -766,9 +902,12 @@
"labels": "Štítky",
"percentDone": "Průběh",
"priority": "Priorita",
"project": "Projekt",
"relatedTasks": "Související úkoly",
"reminders": "Připomínky",
"repeat": "Opakovat",
"comment": "{count} komentář | {count} komentáře",
"commentCount": "Počet komentářů",
"startDate": "Počáteční datum",
"title": "Název",
"updated": "Aktualizováno",
@ -813,7 +952,13 @@
"delete": "Smazat tento komentář",
"deleteText1": "Opravdu chcete smazat tento komentář?",
"deleteSuccess": "Komentář byl úspěšně odstraněn.",
"addedSuccess": "Komentář byl úspěšně přidán."
"addedSuccess": "Komentář byl úspěšně přidán.",
"permalink": "Kopírovat trvalý odkaz na tento komentář",
"sortNewestFirst": "Od nejnovějších",
"sortOldestFirst": "Od nejstarších"
},
"mention": {
"noUsersFound": "Nenalezeni žádní uživatelé"
},
"deferDueDate": {
"title": "Odložit datum dokončení",
@ -922,6 +1067,7 @@
"project4": "Například: {prefix}\"Projekt s mezerami\".",
"dateAndTime": "Datum a čas",
"date": "Jakékoliv datum bude použito jako datum dokončení nového úkolu. Můžete použít data v kterémkoli z těchto formátů:",
"dateWeekday": "jakýkoli pracovní den, použije se další datum s tímto dnem",
"dateCurrentYear": "použije aktuální rok",
"dateNth": "použije {day}. den aktuálního měsíce",
"dateTime": "Pro nastavení času zkombinujte libovolný formát data s \"{time}\" (nebo {timePM}).",
@ -1003,7 +1149,12 @@
"delete": "Smazat tento úkol",
"priority": "Změnit prioritu tohoto úkolu",
"favorite": "Označit tuto úlohu jako oblíbenou / odebrat oblíbené",
"save": "Uložit aktuální úkol"
"openProject": "Otevřít projekt tohoto úkolu",
"save": "Uložit aktuální úkol",
"copyIdentifier": "Kopírovat identifikátor úkolu do schránky",
"copyIdentifierAndTitle": "Kopírovat identifikátor úkolu a název do schránky",
"copyIdentifierTitleAndUrl": "Kopírovat identifikátor úkolu, název a URL do schránky",
"copyUrl": "Kopírovat URL úkolu do schránky"
},
"project": {
"title": "Zobrazení projektu",
@ -1019,6 +1170,21 @@
"labels": "Přejít na štítky",
"teams": "Přejít na týmy",
"projects": "Přejít na projekty"
},
"list": {
"title": "Seznam úkolů",
"navigateDown": "Zvýraznit další úkol",
"navigateUp": "Zvýraznit předchozí úkol",
"open": "Otevřít zvýrazněný úkol"
},
"gantt": {
"title": "Ganttův diagram",
"moveTaskLeft": "Přesunout úkol na dřívější datum",
"moveTaskRight": "Přesunout úkol na pozdější datum",
"expandTaskLeft": "Posuň počáteční datum na dříve",
"expandTaskRight": "Posuň koncové datum na později",
"shrinkTaskLeft": "Posuň počáteční datum na později",
"shrinkTaskRight": "Posuň koncové datum na dříve"
}
},
"update": {
@ -1056,6 +1222,7 @@
"notification": {
"title": "Oznámení",
"none": "Nemáte žádná oznámení. Mějte příjemný den!",
"explainer": "Upozornění se zobrazí zde, když proběhne akce na projektech nebo úkolech, ke kterým jste se přihlásili.",
"markAllRead": "Označit všechna oznámení za přečtená",
"markAllReadSuccess": "Všechna oznámení byla označena jako přečtená."
},
@ -1079,6 +1246,7 @@
}
},
"date": {
"altFormatLong": "j M Y, H:i",
"altFormatShort": "j M Y"
},
"reaction": {
@ -1100,15 +1268,28 @@
"1012": "E-mailová adresa uživatele nebyla potvrzena.",
"1013": "Nové heslo je prázdné.",
"1014": "Staré heslo je prázdné.",
"1015": "TOTP je již pro tohoto uživatele povoleno.",
"1016": "Totp není pro tohoto uživatele povoleno.",
"1017": "Kód TOTP je neplatný.",
"1018": "Nastavení typu avatara uživatele je neplatné.",
"1019": "Poskytovatel OpenID neposkytl žádnou e-mailovou adresu. Ujistěte se, že poskytovatel openID veřejně poskytuje e-mailovou adresu pro váš účet.",
"1020": "Tento účet je zakázán. Zkontrolujte své e-maily nebo se zeptejte správce.",
"1021": "Tento účet je spravován poskytovatelem ověření třetí strany.",
"1022": "Uživatelské jméno nesmí obsahovat mezery.",
"1023": "To nemůžete udělat se sdíleným odkazem.",
"1024": "Neplatná žádost o data pro pole {field} typu {type}.",
"1025": "Časové pásmo '{timezone}' je neplatné. Zvolte prosím platné časové pásmo ze seznamu.",
"2001": "ID nemůže být prázdné nebo 0.",
"2002": "Některé údaje požadavku byly neplatné.",
"2003": "Časové pásmo '{timezone}' je neplatné.",
"3001": "Projekt neexistuje.",
"3004": "Pro provedení této akce musíte mít oprávnění ke čtení k tomuto projektu.",
"3005": "Název projektu nemůže být prázdný.",
"3006": "Sdílení projektu neexistuje.",
"3007": "Projekt s tímto identifikátorem již existuje.",
"3008": "Projekt je archivován, a proto je přístupný pouze pro čtení. To platí i pro všechny úkoly spojené s tímto projektem.",
"4001": "Název úkolu nemůže být prázdný.",
"4002": "Tento úkol neexistuje.",
"4003": "Všechny úkoly pro hromadnou úpravu musí patřit do stejného projektu.",
"4004": "Při hromadných úpravách úkolů je potřeba alespoň jeden úkol.",
"4005": "Nemáte právo vidět tento úkol.",
@ -1126,12 +1307,18 @@
"4017": "Neplatný komparátor filtru úkolů.",
"4018": "Neplatné zřetězení filtru úkolů.",
"4019": "Neplatná hodnota filtru úkolů.",
"4020": "Tato příloha nepatří k tomu úkolu.",
"4021": "Tento uživatel je k tomuto úkolu již přiřazen.",
"4022": "Uveďte prosím datum, s nímž připomenutí souvisí.",
"4023": "Nelze vytvořit cyklus vztahů mezi úkoly.",
"6001": "Název týmu nemůže být prázdný.",
"6002": "Tým neexistuje.",
"6004": "Tým již má k tomuto projektu přístup.",
"6005": "Uživatel je již členem tohoto týmu.",
"6006": "Nelze odstranit posledního člena týmu.",
"6007": "Tým nemá přístup k seznamu pro provedení této akce.",
"6008": "Pro dané OIDC ID a vydavatele nebyl nalezen žádný tým.",
"6009": "Pro uživatele nebyly nalezeny žádné týmy s vlastností oidcId.",
"7002": "Uživatel již má přístup k tomuto projektu.",
"7003": "K tomuto projektu nemáte přístup.",
"8001": "Tento štítek již v tomto úkolu existuje.",
@ -1147,14 +1334,21 @@
"11002": "Uložené filtry nejsou k dispozici pro sdílení odkazů.",
"12001": "Typ předplatného je neplatný.",
"12002": "Již jste přihlášeni k odběru samotného subjektu nebo nadřazeného subjektu.",
"12003": "Musíte zadat uživatele pro načtení odběru.",
"13001": "Tento sdílený odkaz vyžaduje heslo k ověření, ale žádné nebylo poskytnuto.",
"13002": "Zadané heslo pro sdílený odkaz je neplatné.",
"13003": "Zadaný token pro sdílený odkaz je neplatný.",
"14001": "Zadaný api token je neplatný.",
"14002": "Oprávnění {permission} skupiny {group} je neplatné.",
"error": "Chyba",
"success": "Úspěch",
"0001": "Nejste oprávněni to udělat."
},
"about": {
"title": "O aplikaci",
"version": "Verze: {version}"
"version": "Verze: {version}",
"frontendVersion": "Verze frontendu: {version}",
"apiVersion": "Verze API: {version}"
},
"time": {
"units": {

View File

@ -138,10 +138,11 @@
"scanQR": "Alternativ kannst du auch diesen QR-Code scannen:",
"passcode": "Code",
"passcodePlaceholder": "Ein von deiner TOTP-App generierter Code",
"confirmNotice": "Nach der Aktivierung der Zwei-Faktor-Authentifizierung wirst du überall abgemeldet und musst dich erneut anmelden.",
"setupSuccess": "Du hast die Zwei-Faktor-Authentifizierung erfolgreich eingerichtet!",
"enterPassword": "Bitte gib dein Passwort ein",
"disable": "Zwei-Faktor-Authentifizierung deaktivieren",
"confirmSuccess": "TOTP wurde verifiziert und ist nun verwendbar!",
"confirmSuccess": "Du hast die Zwei-Faktor-Authentifizierung erfolgreich eingerichtet!",
"disableSuccess": "Die Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert."
},
"caldav": {
@ -186,6 +187,10 @@
"backgroundBrightness": {
"title": "Hintergrundhelligkeit"
},
"webhooks": {
"title": "Webhook-Benachrichtigungen",
"description": "Konfiguriere Webhook-URLs, um POST-Anfragen zu erhalten, wenn Erinnerungs- oder Fälligkeitsereignisse ausgelöst werden. Diese Webhooks empfangen Ereignisse aus allen deinen Projekten."
},
"apiTokens": {
"title": "API-Tokens",
"general": "Mit API-Token kannst du die API von Vikunja ohne Login-Daten verwenden.",
@ -851,6 +856,7 @@
"doneAt": "Erledigt {0}",
"updateSuccess": "Die Aufgabe wurde erfolgreich gespeichert.",
"deleteSuccess": "Die Aufgabe wurde erfolgreich gelöscht.",
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig {at}",
@ -877,6 +883,7 @@
"attachments": "Anhänge hinzufügen",
"relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben",
"duplicate": "Duplizieren",
"color": "Farbe setzen",
"delete": "Löschen",
"favorite": "Zu Favoriten hinzufügen",

View File

@ -138,10 +138,11 @@
"scanQR": "Alternativ chasch au de QR Code scanne:",
"passcode": "Code",
"passcodePlaceholder": "Ein von deiner TOTP-App generierter Code",
"confirmNotice": "Nach der Aktivierung der Zwei-Faktor-Authentifizierung wirst du überall abgemeldet und musst dich erneut anmelden.",
"setupSuccess": "Du hast die Zwei-Faktor-Authentifizierung erfolgreich eingerichtet!",
"enterPassword": "Bitte gib diis Passwort iih",
"disable": "Zweifaktor Authentifizierig uusschalte",
"confirmSuccess": "TOTP wurde verifiziert und ist nun verwendbar!",
"confirmSuccess": "Du hast die Zwei-Faktor-Authentifizierung erfolgreich eingerichtet!",
"disableSuccess": "Die Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert."
},
"caldav": {
@ -186,6 +187,10 @@
"backgroundBrightness": {
"title": "Hintergrundhelligkeit"
},
"webhooks": {
"title": "Webhook-Benachrichtigungen",
"description": "Konfiguriere Webhook-URLs, um POST-Anfragen zu erhalten, wenn Erinnerungs- oder Fälligkeitsereignisse ausgelöst werden. Diese Webhooks empfangen Ereignisse aus allen deinen Projekten."
},
"apiTokens": {
"title": "API-Tokens",
"general": "Mit API-Token kannst du die API von Vikunja ohne Login-Daten verwenden.",
@ -851,6 +856,7 @@
"doneAt": "{0} erledigt",
"updateSuccess": "Die Uufgab isch erfolgriich g'speichered wore.",
"deleteSuccess": "Die Uufgab isch erfolgriich g'chüblet wore.",
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig bis {at}",
@ -877,6 +883,7 @@
"attachments": "Anhänge hinzufügen",
"relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben",
"duplicate": "Duplizieren",
"color": "Farbe setzen",
"delete": "Löschen",
"favorite": "Zu Favoriten hinzufügen",

View File

@ -138,10 +138,11 @@
"scanQR": "Alternatively you can scan this QR code:",
"passcode": "Passcode",
"passcodePlaceholder": "A code generated by your TOTP application",
"confirmNotice": "After enabling two factor authentication, you will be logged out of all sessions and need to log in again.",
"setupSuccess": "You've successfully set up two factor authentication!",
"enterPassword": "Please Enter Your Password",
"disable": "Disable two factor authentication",
"confirmSuccess": "You've successfully confirmed your TOTP setup and can use it from now on!",
"confirmSuccess": "You've successfully enabled two factor authentication!",
"disableSuccess": "Two factor authentication was successfully disabled."
},
"caldav": {
@ -186,6 +187,10 @@
"backgroundBrightness": {
"title": "Background brightness"
},
"webhooks": {
"title": "Webhook Notifications",
"description": "Configure webhook URLs to receive POST requests when reminder or overdue events fire. These webhooks receive events from all your projects."
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
@ -202,6 +207,13 @@
"tokenCreatedSuccess": "Here is your new api token: {token}",
"tokenCreatedNotSeeAgain": "Store it in a secure location, you won't see it again!",
"selectAll": "Select all",
"presets": {
"title": "Quick presets",
"readOnly": "Read only",
"tasks": "Task management",
"projects": "Project management",
"fullAccess": "Full access"
},
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",

View File

@ -91,6 +91,7 @@
"discoverableByName": "Salli muiden käyttäjien lisätä minut jäseneksi tiimeihin tai projekteihin, kun he etsivät nimeäni",
"discoverableByEmail": "Salli muiden käyttäjien lisätä minut jäseneksi tiimeihin tai projekteihin, kun he etsivät koko sähköpostiani",
"playSoundWhenDone": "Toista ääni, kun tehtävä merkitään valmiiksi",
"alwaysShowBucketTaskCount": "Näytä aina tehtävien lukumäärä Kanban sarakkeissa",
"weekStart": "Viikon ensimmäinen päivä",
"weekStartSunday": "Sunnuntai",
"weekStartMonday": "Maanantai",
@ -134,7 +135,6 @@
"setupSuccess": "Olet määrittänyt kaksivaiheinen tunnistautumisen onnistuneesti!",
"enterPassword": "Ole Hyvä ja Syötä Salasanasi",
"disable": "Poista kaksivaiheinen tunnistautuminen käytöstä",
"confirmSuccess": "TOTP määritys on tehty onnistuneesti ja voit käyttää sitä tästä hetkestä lähtien!",
"disableSuccess": "Kaksivaiheinen tunnistautuminen poistettu käytöstä onnistuneesti."
},
"caldav": {

View File

@ -133,7 +133,6 @@
"setupSuccess": "Vous avez maintenant configuré lauthentification à deux facteurs !",
"enterPassword": "Saisissez votre mot de passe",
"disable": "Désactiver lauthentification à deux facteurs",
"confirmSuccess": "La configuration de l'A2F est confirmée et vous pouvez lutiliser dès à présent !",
"disableSuccess": "Lauthentification à deux facteurs a bien été désactivée."
},
"caldav": {

View File

@ -114,7 +114,6 @@
"setupSuccess": "העמדת אימות דו שלבי בהצלחה!",
"enterPassword": "נא להכניס סיסמתך",
"disable": "לבטל אימות דו שלבי",
"confirmSuccess": "אישרת העמדת TOTP בהצלחה. ניתן להשתמש מעכשיו והלאה!",
"disableSuccess": "אימות דו שלבי בוטלה בהצלחה."
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "Autenticazione a due fattori abilitata!",
"enterPassword": "Inserisci La Tua Password",
"disable": "Disabilita l'autenticazione a due fattori",
"confirmSuccess": "Hai confermato con successo la tua configurazione TOTP e ora puoi utilizzarla!",
"disableSuccess": "Autenticazione a due fattori disabilitata."
},
"caldav": {

View File

@ -141,7 +141,6 @@
"setupSuccess": "2要素認証は正常に設定されました。",
"enterPassword": "パスワードを入力してください",
"disable": "2要素認証の無効化",
"confirmSuccess": "2要素認証のセットアップを完了しました。",
"disableSuccess": "2要素認証は無効化されました。"
},
"caldav": {
@ -851,6 +850,7 @@
"doneAt": "完了 {0}",
"updateSuccess": "タスクは正常に保存されました。",
"deleteSuccess": "タスクは正常に削除されました。",
"duplicateSuccess": "タスクは正常に複製されました。",
"belongsToProject": "このタスクはプロジェクト「{project}」に含まれています。",
"back": "プロジェクトに戻る",
"due": "期限: {at}",
@ -877,6 +877,7 @@
"attachments": "添付ファイルの追加",
"relatedTasks": "関連タスクの追加",
"moveProject": "移動",
"duplicate": "複製",
"color": "色の設定",
"delete": "削除",
"favorite": "お気に入りに追加",

View File

@ -114,7 +114,6 @@
"setupSuccess": "2단계 인증을 성공적으로 활성화했습니다!",
"enterPassword": "비밀번호를 입력하여 주십시오.",
"disable": "2단계 인증 비활성화",
"confirmSuccess": "TOTP 설정이 성공적으로 확인되었으며 이제부터 사용할 수 있습니다!",
"disableSuccess": "2단계 인증이 비활성화 되었습니다."
},
"caldav": {

View File

@ -113,7 +113,6 @@
"setupSuccess": "Sėkmingai nustatei dviejų faktorių identifikavimą!",
"enterPassword": "Prašome įvesti savo slaptažodį",
"disable": "Išjungti dviejų faktoriu identifikavimą",
"confirmSuccess": "Tu sėkmingai patvirtinai savo TOTP nustatymus ir gail naudotis nuo dabar!",
"disableSuccess": "Dviejų faktorių identifikavimas buvo sėkmingai išjungtas."
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "Je hebt tweestapsverificatie succesvol ingesteld!",
"enterPassword": "Voer alsjeblieft je wachtwoord in",
"disable": "Tweestapsverificatie uitschakelen",
"confirmSuccess": "Je hebt je TOTP-configuratie succesvol bevestigd en kunt het van nu gebruiken!",
"disableSuccess": "Uitschakelen tweestapsverificatie is geslaagd."
},
"caldav": {

View File

@ -134,7 +134,6 @@
"setupSuccess": "Du har nå deaktivert to-faktorautentisering!",
"enterPassword": "Skriv inn ditt passord",
"disable": "Skru av to-faktorautentisering",
"confirmSuccess": "Du har bekreftet ditt totp-oppsett og kan bruke det fra nå av!",
"disableSuccess": "To-faktorautentisering ble skrudd av."
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "Configuraste com sucesso a autenticação de dois fatores!",
"enterPassword": "Por favor, insere a tua palavra-passe",
"disable": "Desativar a autenticação de dois fatores",
"confirmSuccess": "Confirmaste com sucesso a tua configuração TOTP e podes utilizá-la de agora em diante!",
"disableSuccess": "A autenticação de dois fatores foi desativada com sucesso."
},
"caldav": {

View File

@ -68,7 +68,8 @@
"alreadyHaveAnAccount": "Уже есть аккаунт?",
"remember": "Оставаться в системе",
"registrationDisabled": "Регистрация отключена.",
"passwordResetTokenMissing": "Не указан токен сброса пароля."
"passwordResetTokenMissing": "Не указан токен сброса пароля.",
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
},
"settings": {
"title": "Настройки",
@ -93,6 +94,7 @@
"discoverableByEmail": "Разрешить другим пользователям добавлять меня в состав команд или проектов при поиске моего полного email",
"playSoundWhenDone": "Проигрывать звук, когда задача помечается завершённой",
"allowIconChanges": "Показывать специальные логотипы в определённое время",
"alwaysShowBucketTaskCount": "Всегда показывать количество задач в колонках Канбана",
"defaultTaskRelationType": "Тип связанной задачи по умолчанию",
"weekStart": "Первый день недели",
"weekStartSunday": "Воскресенье",
@ -136,10 +138,11 @@
"scanQR": "Или вы можете отсканировать этот QR-код:",
"passcode": "Код",
"passcodePlaceholder": "Код, который сгенерировало приложение TOTP",
"confirmNotice": "После включения двухфакторной аутентификации все ваши сеансы будут завершены и вам нужно будет войти заново.",
"setupSuccess": "Двухфакторная аутентификация успешно подключена!",
"enterPassword": "Введите свой пароль",
"disable": "Отключить двухфакторную аутентификацию",
"confirmSuccess": "TOTP настроен и готов к использованию!",
"confirmSuccess": "Двухфакторная аутентификация успешно подключена!",
"disableSuccess": "Двухфакторная аутентификация отключена."
},
"caldav": {
@ -184,6 +187,10 @@
"backgroundBrightness": {
"title": "Яркость фона"
},
"webhooks": {
"title": "Уведомления через вебхуки",
"description": "Настройка адресов, которые будут получать POST-запросы при достижении времени напоминания или истечении срока задачи. Эти вебхуки будут получать события из всех ваших проектов."
},
"apiTokens": {
"title": "Токены API",
"general": "Токены API позволяют использовать Vikunja API без использования данных для входа пользователя.",
@ -211,6 +218,20 @@
"expiresAt": "Срок действия",
"permissions": "Разрешения"
}
},
"sessions": {
"title": "Сеансы",
"description": "Список всех устройств, на которых выполнен вход в ваш аккаунт. Вы можете отозвать любой сеанс, чтобы выйти из аккаунта на указанном устройстве. Это может занять до 10 минут.",
"deviceInfo": "Устройство",
"ipAddress": "IP-адрес",
"lastActive": "Последняя активность",
"current": "Текущий сеанс",
"delete": {
"header": "Отозвать сеанс",
"text": "Вы уверены, что хотите отозвать этот сеанс? Будет выполнен выход из аккаунта на этом устройстве. Это может занять до 10 минут."
},
"deleteSuccess": "Сеанс отозван. Полное завершение его действия может занять до 10 минут.",
"noOtherSessions": "Нет других активных сеансов."
}
},
"deletion": {
@ -389,7 +410,11 @@
"dayLabel": "День: {date}, {weekday}",
"dayLabelToday": "Сегодня: {date}, {weekday}",
"taskAriaLabel": "Задача: {task}",
"taskAriaLabelById": "Задача {id}"
"taskAriaLabelById": "Задача {id}",
"partialDatesStart": "Только дата начала (без окончания)",
"partialDatesEnd": "Только дата окончания (без начала)",
"expandGroup": "Развернуть группу: {task}",
"collapseGroup": "Свернуть группу: {task}"
},
"table": {
"title": "Таблица",
@ -438,6 +463,9 @@
"deleteSuccess": "Вебхук успешно удалён.",
"create": "Создать вебхук",
"secret": "Секрет",
"basicauthuser": "Basic Auth пользователь",
"basicauthpassword": "Basic Auth пароль",
"basicauthlink": "Использовать HTTP Basic аутентификацию?",
"secretHint": "Если указан, все запросы к URL обработчика будут подписаны с помощью HMAC.",
"secretDocs": "Подробнее об использовании секретов в документации."
},
@ -798,6 +826,7 @@
"addReminder": "Добавить напоминание…",
"doneSuccess": "Задача отмечена как завершённая.",
"undoneSuccess": "Задача отмечена как незавершённая.",
"movedToProject": "Задача перемещена в проект «{project}».",
"undo": "Отменить",
"openDetail": "Открыть подробный просмотр задачи",
"checklistTotal": "{checked} из {total} задач",
@ -825,10 +854,12 @@
"doneAt": "Завершено {0}",
"updateSuccess": "Задача сохранена.",
"deleteSuccess": "Задача удалена.",
"duplicateSuccess": "Задача продублирована.",
"belongsToProject": "Задача принадлежит проекту «{project}»",
"back": "Вернуться к проекту",
"due": "Истекает {at}",
"closePopup": "Закрыть всплывающее окно",
"scrollToBottom": "Прокрутить до конца страницы",
"organization": "Организация",
"management": "Управление",
"dateAndTime": "Дата и время",
@ -850,6 +881,7 @@
"attachments": "Добавить вложения",
"relatedTasks": "Добавить связь",
"moveProject": "Переместить",
"duplicate": "Создать копию",
"color": "Выбрать цвет",
"delete": "Удалить",
"favorite": "Добавить в избранное",
@ -868,6 +900,7 @@
"labels": "Метки",
"percentDone": "Прогресс",
"priority": "Приоритет",
"project": "Проект",
"relatedTasks": "Связанные задачи",
"reminders": "Напоминания",
"repeat": "Повтор",
@ -918,7 +951,9 @@
"deleteText1": "Удалить этот комментарий?",
"deleteSuccess": "Комментарий удалён.",
"addedSuccess": "Комментарий добавлен.",
"permalink": "Скопировать постоянную ссылку на комментарий"
"permalink": "Скопировать постоянную ссылку на комментарий",
"sortNewestFirst": "Сначала новые",
"sortOldestFirst": "Сначала старые"
},
"mention": {
"noUsersFound": "Пользователи не найдены"
@ -1113,7 +1148,11 @@
"priority": "Изменить приоритет задачи",
"favorite": "Добавить задачу в избранное или удалить из избранного",
"openProject": "Открыть проект с этой задачей",
"save": "Сохранить текущую задачу"
"save": "Сохранить текущую задачу",
"copyIdentifier": "Скопировать идентификатор задачи в буфер обмена",
"copyIdentifierAndTitle": "Скопировать идентификатор и заголовок задачи в буфер обмена",
"copyIdentifierTitleAndUrl": "Скопировать идентификатор, заголовок и URL задачи в буфер обмена",
"copyUrl": "Скопировать URL задачи в буфер обмена"
},
"project": {
"title": "Просмотр проекта",

View File

@ -112,7 +112,6 @@
"setupSuccess": "Uspešno ste nastavili dvofaktorsko avtentikacijo!",
"enterPassword": "Prosim vnesite vaše geslo",
"disable": "Onemogoči dvofaktorsko avtentikacijo",
"confirmSuccess": "Uspešno ste potrdili svojo nastavitev TOTP in jo lahko od zdaj naprej uporabljate!",
"disableSuccess": "Dvofaktorska avtentikacija je bila uspešno onemogočena."
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "Du har framgångsrikt konfigurerat tvåfaktorsautentisering!",
"enterPassword": "Vänligen ange ditt lösenord",
"disable": "Inaktivera tvåfaktorsautentisering",
"confirmSuccess": "Du har bekräftat din TOTP-konfiguration och kan använda den från och med nu!",
"disableSuccess": "Tvåfaktorsautentisering har blivit inaktiverad."
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "İki faktörlü kimlik doğrulamayı başarıyla kurdunuz!",
"enterPassword": "Lütfen Şifrenizi Girin",
"disable": "İki faktörlü kimlik doğrulamayı devre dışı bırak",
"confirmSuccess": "TOTP kurulumunuzu başarıyla onayladınız ve artık kullanabilirsiniz!",
"disableSuccess": "İki faktörlü kimlik doğrulama başarıyla devre dışı bırakıldı."
},
"caldav": {

View File

@ -126,7 +126,6 @@
"setupSuccess": "Дворівневу перевірку встановлено!",
"enterPassword": "Будь ласка, введіть ваш пароль",
"disable": "Вимкнути дворівневу перевірку",
"confirmSuccess": "Наладжено разовий пароль і можна вживати його!",
"disableSuccess": "Дворівневу перевірку вимкнено."
},
"caldav": {

View File

@ -112,7 +112,6 @@
"setupSuccess": "Bạn đã thiết lập thành công xác thực hai lớp!",
"enterPassword": "Vui lòng nhập mật khẩu",
"disable": "Tắt xác thực 2 lớp",
"confirmSuccess": "Bạn đã xác nhận thành công cài đặt TOTP và có thể sự dụng từ bây giờ!",
"disableSuccess": "Xác thực hai lớp đã bị vô hiệu hóa thành công."
},
"caldav": {

View File

@ -120,7 +120,6 @@
"setupSuccess": "您已成功启用两步验证!",
"enterPassword": "请输入密码",
"disable": "禁用两步验证",
"confirmSuccess": "您已成功确认您的 TOTP 设置,并且可以从现在起使用!",
"disableSuccess": "两步验证已禁用。"
},
"caldav": {

View File

@ -140,7 +140,6 @@
"setupSuccess": "您已成功啟用雙重驗證!",
"enterPassword": "請輸入密碼",
"disable": "停用雙重驗證",
"confirmSuccess": "您已成功確認您的 TOTP 設定,並且可以從現在起使用!",
"disableSuccess": "雙重驗證已停用。"
},
"caldav": {

View File

@ -1,4 +1,3 @@
import type {IAbstract} from './IAbstract'
import type {IProject} from './IProject'
import type {PrefixMode} from '@/modules/parseTaskText'

View File

@ -4,9 +4,10 @@ import type {IUser} from '@/modelTypes/IUser'
export interface IWebhook extends IAbstract {
id: number
projectId: number
secret: string
userId: number
secret: string
basicauthuser: string
basicauthpassword: string
basicauthpassword: string
targetUrl: string
events: string[]
createdBy: IUser

View File

@ -25,11 +25,13 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
quickAddMagicMode: PrefixMode.Default,
colorSchema: 'auto',
allowIconChanges: true,
filterIdUsedOnOverview: null,
defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST,
minimumPriority: PRIORITIES.MEDIUM,
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: null,
alwaysShowBucketTaskCount: false,
sidebarWidth: null,
commentSortOrder: 'asc',

View File

@ -5,6 +5,7 @@ import UserModel from '@/models/user'
export default class WebhookModel extends AbstractModel<IWebhook> implements IWebhook {
id = 0
projectId = 0
userId = 0
secret = ''
basicauthuser = ''
basicauthpassword = ''

View File

@ -1,306 +0,0 @@
import {parseDate} from '../helpers/time/parseDate'
import {PRIORITIES} from '@/constants/priorities'
import {REPEAT_TYPES, type IRepeatAfter, type IRepeatType} from '@/types/IRepeatAfter'
const VIKUNJA_PREFIXES: Prefixes = {
label: '*',
project: '+',
priority: '!',
assignee: '@',
}
const TODOIST_PREFIXES: Prefixes = {
label: '@',
project: '#',
priority: '!',
assignee: '+',
}
export enum PrefixMode {
Disabled = 'disabled',
Default = 'vikunja',
Todoist = 'todoist',
}
export const PREFIXES = {
[PrefixMode.Disabled]: undefined,
[PrefixMode.Default]: VIKUNJA_PREFIXES,
[PrefixMode.Todoist]: TODOIST_PREFIXES,
}
interface repeatParsedResult {
textWithoutMatched: string,
repeats: IRepeatAfter | null,
}
export interface ParsedTaskText {
text: string,
date: Date | null,
labels: string[],
project: string | null,
priority: number | null,
assignees: string[],
repeats: IRepeatAfter | null,
}
interface Prefixes {
label: string,
project: string,
priority: string,
assignee: string,
}
/**
* Parses task text for dates, assignees, labels, projects, priorities and returns an object with all found intents.
*
* @param text
*/
export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMode.Default, now: Date = new Date()): ParsedTaskText => {
const result: ParsedTaskText = {
text: text,
date: null,
labels: [],
project: null,
priority: null,
assignees: [],
repeats: null,
}
const prefixes = PREFIXES[prefixesMode]
if (prefixes === undefined) {
return result
}
result.labels = getLabelsFromPrefix(text, prefixesMode) ?? []
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
result.project = getProjectFromPrefix(result.text, prefixesMode)
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.priority = getPriority(result.text, prefixes.priority)
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
result.assignees = getItemsFromPrefix(result.text, prefixes.assignee)
const {textWithoutMatched, repeats} = getRepeats(result.text)
result.text = textWithoutMatched
result.repeats = repeats
const {newText, date} = parseDate(result.text, now)
result.text = newText
result.date = date
return cleanupResult(result, prefixes)
}
const getItemsFromPrefix = (text: string, prefix: string): string[] => {
const items: string[] = []
const itemParts = text.split(' ' + prefix)
if (text.startsWith(prefix)) {
const firstItem = text.split(prefix)[1]
itemParts.unshift(firstItem)
}
itemParts.forEach((p, index) => {
// First part contains the rest
if (index < 1) {
return
}
if (p.startsWith(prefix)) {
p = p.substring(1)
}
let itemText
if (p.charAt(0) === '\'') {
itemText = p.split('\'')[1]
} else if (p.charAt(0) === '"') {
itemText = p.split('"')[1]
} else {
// Only until the next space
itemText = p.split(' ')[0]
}
if (itemText !== '') {
items.push(itemText)
}
})
return Array.from(new Set(items))
}
export const getProjectFromPrefix = (text: string, prefixMode: PrefixMode): string | null => {
const projectPrefix = PREFIXES[prefixMode]?.project
if(typeof projectPrefix === 'undefined') {
return null
}
const projects: string[] = getItemsFromPrefix(text, projectPrefix)
return projects.length > 0 ? projects[0] : null
}
export const getLabelsFromPrefix = (text: string, prefixMode: PrefixMode): string[] | null => {
const labelsPrefix = PREFIXES[prefixMode]?.label
if(typeof labelsPrefix === 'undefined') {
return null
}
return getItemsFromPrefix(text, labelsPrefix)
}
const getPriority = (text: string, prefix: string): number | null => {
const ps = getItemsFromPrefix(text, prefix)
if (ps.length === 0) {
return null
}
for (const p of ps) {
for (const pi of Object.values(PRIORITIES)) {
if (pi === parseInt(p)) {
return parseInt(p)
}
}
}
return null
}
const getRepeats = (text: string): repeatParsedResult => {
const regex = /(^| )(((every|each) (([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten) )?(hours?|days?|weeks?|months?|years?))|(annually|biannually|semiannually|biennially|daily|hourly|monthly|weekly|yearly))($| )/ig
const results = regex.exec(text)
if (results === null) {
return {
textWithoutMatched: text,
repeats: null,
}
}
let amount = 1
switch (results[5] ? results[5].trim() : undefined) {
case 'one':
amount = 1
break
case 'two':
amount = 2
break
case 'three':
amount = 3
break
case 'four':
amount = 4
break
case 'five':
amount = 5
break
case 'six':
amount = 6
break
case 'seven':
amount = 7
break
case 'eight':
amount = 8
break
case 'nine':
amount = 9
break
case 'ten':
amount = 10
break
default:
amount = results[5] ? parseInt(results[5]) : 1
}
let type: IRepeatType = REPEAT_TYPES.Hours
switch (results[2]) {
case 'biennially':
type = REPEAT_TYPES.Years
amount = 2
break
case 'biannually':
case 'semiannually':
type = REPEAT_TYPES.Months
amount = 6
break
case 'yearly':
case 'annually':
type = REPEAT_TYPES.Years
break
case 'daily':
type = REPEAT_TYPES.Days
break
case 'hourly':
type = REPEAT_TYPES.Hours
break
case 'monthly':
type = REPEAT_TYPES.Months
break
case 'weekly':
type = REPEAT_TYPES.Weeks
break
default:
switch (results[7]) {
case 'hour':
case 'hours':
type = REPEAT_TYPES.Hours
break
case 'day':
case 'days':
type = REPEAT_TYPES.Days
break
case 'week':
case 'weeks':
type = REPEAT_TYPES.Weeks
break
case 'month':
case 'months':
type = REPEAT_TYPES.Months
break
case 'year':
case 'years':
type = REPEAT_TYPES.Years
break
}
}
let matchedText = results[0]
if(matchedText.endsWith(' ')) {
matchedText = matchedText.substring(0, matchedText.length - 1)
}
return {
textWithoutMatched: text.replace(matchedText, ''),
repeats: {
amount,
type,
},
}
}
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const cleanupItemText = (text: string, items: string[], prefix: string): string => {
items.forEach(l => {
if (l === '') {
return
}
const escaped = escapeRegExp(l)
text = text
.replace(new RegExp(`\\${prefix}'${escaped}' `, 'ig'), '')
.replace(new RegExp(`\\${prefix}'${escaped}'`, 'ig'), '')
.replace(new RegExp(`\\${prefix}"${escaped}" `, 'ig'), '')
.replace(new RegExp(`\\${prefix}"${escaped}"`, 'ig'), '')
.replace(new RegExp(`\\${prefix}${escaped} `, 'ig'), '')
.replace(new RegExp(`\\${prefix}${escaped}`, 'ig'), '')
})
return text
}
const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => {
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
// Not removing assignees to avoid removing @text where the user does not exist
result.text = result.text.trim()
return result
}

View File

@ -1,6 +1,6 @@
import {calculateDayInterval} from './calculateDayInterval'
import {calculateNearestHours} from './calculateNearestHours'
import {replaceAll} from '../replaceAll'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {replaceAll} from '@/helpers/replaceAll'
export interface dateParseResult {
newText: string,
@ -196,7 +196,7 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
result = `${month}/${day}/${tmp_year}`
result = !isNaN(new Date(result).getTime()) ? result : `${day}/${month}/${tmp_year}`
result = !isNaN(new Date(result).getTime()) ? result : null
if(result !== null){
foundText = found
break
@ -212,7 +212,7 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
foundText = results === null ? '' : results[0].trim()
containsYear = false
}
if (result === null) {
return {
foundText,
@ -328,7 +328,7 @@ const getDateFromWeekday = (text: string, date: Date = new Date()): dateFoundRes
const distance: number = (day + 7 - currentDay) % 7
date.setDate(date.getDate() + distance)
// This a space at the end of the found text to not break parsing suffix strings like "at 14:00" in cases where the
// This a space at the end of the found text to not break parsing suffix strings like "at 14:00" in cases where the
// matched string comes with a space at the end (last part of the regex).
let foundText = results[0]
if (foundText.endsWith(' ')) {
@ -357,9 +357,9 @@ const getDayFromText = (text: string, now: Date = new Date()) => {
const day = parseInt(results[0])
date.setDate(day)
// If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
// If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
// setting the day to 31 will "overflow" the date to the next month, but the first.
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
while (date < now) {
date.setMonth(date.getMonth() + 1)

View File

@ -0,0 +1,5 @@
export {parseTaskText} from './parseTaskText'
export {PrefixMode, PREFIXES} from './prefixes'
export {getLabelsFromPrefix, getProjectFromPrefix} from './prefixParser'
export {cleanupItemText} from './textCleanup'
export type {ParsedTaskText} from './types'

View File

@ -1,8 +1,9 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ParsedTaskText, parseTaskText, PrefixMode} from './parseTaskText'
import {parseDate} from '../helpers/time/parseDate'
import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
import {parseTaskText, PrefixMode} from '.'
import type {ParsedTaskText} from '.'
import {parseDate} from './dateParser'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {PRIORITIES} from '@/constants/priorities'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import type {IRepeatAfter} from '@/types/IRepeatAfter'

View File

@ -0,0 +1,50 @@
import {parseDate} from './dateParser'
import {PREFIXES, PrefixMode} from './prefixes'
import {getItemsFromPrefix, getLabelsFromPrefix, getProjectFromPrefix} from './prefixParser'
import {getPriority} from './priorityParser'
import {getRepeats} from './repeatParser'
import {cleanupItemText, cleanupResult} from './textCleanup'
import type {ParsedTaskText} from './types'
/**
* Parses task text for dates, assignees, labels, projects, priorities and returns an object with all found intents.
*
* @param text
*/
export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMode.Default, now: Date = new Date()): ParsedTaskText => {
const result: ParsedTaskText = {
text: text,
date: null,
labels: [],
project: null,
priority: null,
assignees: [],
repeats: null,
}
const prefixes = PREFIXES[prefixesMode]
if (prefixes === undefined) {
return result
}
result.labels = getLabelsFromPrefix(text, prefixesMode) ?? []
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
result.project = getProjectFromPrefix(result.text, prefixesMode)
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.priority = getPriority(result.text, prefixes.priority)
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
result.assignees = getItemsFromPrefix(result.text, prefixes.assignee)
const {textWithoutMatched, repeats} = getRepeats(result.text)
result.text = textWithoutMatched
result.repeats = repeats
const {newText, date} = parseDate(result.text, now)
result.text = newText
result.date = date
return cleanupResult(result, prefixes)
}

View File

@ -0,0 +1,55 @@
import {PREFIXES, PrefixMode} from './prefixes'
export const getItemsFromPrefix = (text: string, prefix: string): string[] => {
const items: string[] = []
const itemParts = text.split(' ' + prefix)
if (text.startsWith(prefix)) {
const firstItem = text.split(prefix)[1]
itemParts.unshift(firstItem)
}
itemParts.forEach((p, index) => {
// First part contains the rest
if (index < 1) {
return
}
if (p.startsWith(prefix)) {
p = p.substring(1)
}
let itemText
if (p.charAt(0) === '\'') {
itemText = p.split('\'')[1]
} else if (p.charAt(0) === '"') {
itemText = p.split('"')[1]
} else {
// Only until the next space
itemText = p.split(' ')[0]
}
if (itemText !== '') {
items.push(itemText)
}
})
return Array.from(new Set(items))
}
export const getProjectFromPrefix = (text: string, prefixMode: PrefixMode): string | null => {
const projectPrefix = PREFIXES[prefixMode]?.project
if(typeof projectPrefix === 'undefined') {
return null
}
const projects: string[] = getItemsFromPrefix(text, projectPrefix)
return projects.length > 0 ? projects[0] : null
}
export const getLabelsFromPrefix = (text: string, prefixMode: PrefixMode): string[] | null => {
const labelsPrefix = PREFIXES[prefixMode]?.label
if(typeof labelsPrefix === 'undefined') {
return null
}
return getItemsFromPrefix(text, labelsPrefix)
}

View File

@ -0,0 +1,27 @@
import type {Prefixes} from './types'
const VIKUNJA_PREFIXES: Prefixes = {
label: '*',
project: '+',
priority: '!',
assignee: '@',
}
const TODOIST_PREFIXES: Prefixes = {
label: '@',
project: '#',
priority: '!',
assignee: '+',
}
export enum PrefixMode {
Disabled = 'disabled',
Default = 'vikunja',
Todoist = 'todoist',
}
export const PREFIXES = {
[PrefixMode.Disabled]: undefined,
[PrefixMode.Default]: VIKUNJA_PREFIXES,
[PrefixMode.Todoist]: TODOIST_PREFIXES,
}

View File

@ -0,0 +1,19 @@
import {PRIORITIES} from '@/constants/priorities'
import {getItemsFromPrefix} from './prefixParser'
export const getPriority = (text: string, prefix: string): number | null => {
const ps = getItemsFromPrefix(text, prefix)
if (ps.length === 0) {
return null
}
for (const p of ps) {
for (const pi of Object.values(PRIORITIES)) {
if (pi === parseInt(p)) {
return parseInt(p)
}
}
}
return null
}

View File

@ -0,0 +1,114 @@
import {REPEAT_TYPES, type IRepeatType} from '@/types/IRepeatAfter'
import type {repeatParsedResult} from './types'
export const getRepeats = (text: string): repeatParsedResult => {
const regex = /(^| )(((every|each) (([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten) )?(hours?|days?|weeks?|months?|years?))|(annually|biannually|semiannually|biennially|daily|hourly|monthly|weekly|yearly))($| )/ig
const results = regex.exec(text)
if (results === null) {
return {
textWithoutMatched: text,
repeats: null,
}
}
let amount = 1
switch (results[5] ? results[5].trim() : undefined) {
case 'one':
amount = 1
break
case 'two':
amount = 2
break
case 'three':
amount = 3
break
case 'four':
amount = 4
break
case 'five':
amount = 5
break
case 'six':
amount = 6
break
case 'seven':
amount = 7
break
case 'eight':
amount = 8
break
case 'nine':
amount = 9
break
case 'ten':
amount = 10
break
default:
amount = results[5] ? parseInt(results[5]) : 1
}
let type: IRepeatType = REPEAT_TYPES.Hours
switch (results[2]) {
case 'biennially':
type = REPEAT_TYPES.Years
amount = 2
break
case 'biannually':
case 'semiannually':
type = REPEAT_TYPES.Months
amount = 6
break
case 'yearly':
case 'annually':
type = REPEAT_TYPES.Years
break
case 'daily':
type = REPEAT_TYPES.Days
break
case 'hourly':
type = REPEAT_TYPES.Hours
break
case 'monthly':
type = REPEAT_TYPES.Months
break
case 'weekly':
type = REPEAT_TYPES.Weeks
break
default:
switch (results[7]) {
case 'hour':
case 'hours':
type = REPEAT_TYPES.Hours
break
case 'day':
case 'days':
type = REPEAT_TYPES.Days
break
case 'week':
case 'weeks':
type = REPEAT_TYPES.Weeks
break
case 'month':
case 'months':
type = REPEAT_TYPES.Months
break
case 'year':
case 'years':
type = REPEAT_TYPES.Years
break
}
}
let matchedText = results[0]
if(matchedText.endsWith(' ')) {
matchedText = matchedText.substring(0, matchedText.length - 1)
}
return {
textWithoutMatched: text.replace(matchedText, ''),
repeats: {
amount,
type,
},
}
}

View File

@ -0,0 +1,30 @@
import type {ParsedTaskText, Prefixes} from './types'
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const cleanupItemText = (text: string, items: string[], prefix: string): string => {
items.forEach(l => {
if (l === '') {
return
}
const escaped = escapeRegExp(l)
text = text
.replace(new RegExp(`\\${prefix}'${escaped}' `, 'ig'), '')
.replace(new RegExp(`\\${prefix}'${escaped}'`, 'ig'), '')
.replace(new RegExp(`\\${prefix}"${escaped}" `, 'ig'), '')
.replace(new RegExp(`\\${prefix}"${escaped}"`, 'ig'), '')
.replace(new RegExp(`\\${prefix}${escaped} `, 'ig'), '')
.replace(new RegExp(`\\${prefix}${escaped}`, 'ig'), '')
})
return text
}
export const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => {
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
// Not removing assignees to avoid removing @text where the user does not exist
result.text = result.text.trim()
return result
}

View File

@ -0,0 +1,23 @@
import type {IRepeatAfter} from '@/types/IRepeatAfter'
export interface repeatParsedResult {
textWithoutMatched: string,
repeats: IRepeatAfter | null,
}
export interface ParsedTaskText {
text: string,
date: Date | null,
labels: string[],
project: string | null,
priority: number | null,
assignees: string[],
repeats: IRepeatAfter | null,
}
export interface Prefixes {
label: string,
project: string,
priority: string,
assignee: string,
}

View File

@ -144,6 +144,11 @@ const router = createRouter({
name: 'user.settings.sessions',
component: () => import('@/views/user/settings/Sessions.vue'),
},
{
path: '/user/settings/webhooks',
name: 'user.settings.webhooks',
component: () => import('@/views/user/settings/Webhooks.vue'),
},
{
path: '/user/settings/migrate',
name: 'migrate.start',

View File

@ -27,3 +27,29 @@ export default class WebhookService extends AbstractService<IWebhook> {
}
}
}
export class UserWebhookService extends AbstractService<IWebhook> {
constructor() {
super({
getAll: '/user/settings/webhooks',
create: '/user/settings/webhooks',
update: '/user/settings/webhooks/{id}',
delete: '/user/settings/webhooks/{id}',
})
}
modelFactory(data) {
return new WebhookModel(data)
}
async getAvailableEvents(): Promise<string[]> {
const cancel = this.setLoading()
try {
const response = await this.http.get('/user/settings/webhooks/events')
return response.data
} finally {
cancel()
}
}
}

View File

@ -1,40 +0,0 @@
import {ref, computed, readonly} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {findIndexById} from '@/helpers/utils'
import type {IAttachment} from '@/modelTypes/IAttachment'
export const useAttachmentStore = defineStore('attachment', () => {
const attachments = ref<IAttachment[]>([])
function set(newAttachments: IAttachment[]) {
console.debug('Set attachments', newAttachments)
attachments.value = newAttachments
}
function add(attachment: IAttachment) {
console.debug('Add attachement', attachment)
attachments.value.push(attachment)
}
function removeById(id: IAttachment['id']) {
const attachmentIndex = findIndexById(attachments.value, id)
attachments.value.splice(attachmentIndex, 1)
console.debug('Remove attachement', id)
}
const hasAttachments = computed(() => attachments.value.length > 0)
return {
attachments: readonly(attachments),
set,
add,
removeById,
hasAttachments,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAttachmentStore, import.meta.hot))
}

View File

@ -337,7 +337,14 @@ export const useAuthStore = defineStore('auth', () => {
}
if (isAuthenticated) {
await refreshUserInfo()
const user = await refreshUserInfo()
if (!user) {
// refreshUserInfo() did not return a user — either the
// token vanished or a 4xx triggered logout(). Bail out
// so the stale local `isAuthenticated` doesn't override
// the auth state that logout() already set.
return
}
}
}

View File

@ -28,6 +28,7 @@ export interface ConfigState {
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
demoModeEnabled: boolean,
webhooksEnabled: boolean,
auth: {
local: {
enabled: boolean,
@ -66,6 +67,7 @@ export const useConfigStore = defineStore('config', () => {
userDeletionEnabled: true,
taskCommentsEnabled: true,
demoModeEnabled: false,
webhooksEnabled: false,
auth: {
local: {
enabled: true,
@ -85,12 +87,13 @@ export const useConfigStore = defineStore('config', () => {
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)
const apiBase = computed(() => {
const {host, protocol, href} = parseURL(window.API_URL)
const {host, protocol, pathname} = parseURL(window.API_URL)
const cleanHref = href ? (href.endsWith('/')
? href.slice(0, -1)
: href) : ''
return `${protocol}//${host}${cleanHref ? `/${cleanHref}` : ''}`
// Strip the /api/v1 suffix (and optional trailing slash) to get the deployment base.
const basePath = pathname
.replace(/\/api\/v1\/?$/, '')
.replace(/\/+$/, '')
return `${protocol}//${host}${basePath}`
})
function setConfig(config: ConfigState) {

View File

@ -25,7 +25,6 @@ import type {IProject} from '@/modelTypes/IProject'
import {setModuleLoading} from '@/stores/helper'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useAttachmentStore} from '@/stores/attachments'
import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
import ProjectUserService from '@/services/projectUsers'
@ -108,7 +107,6 @@ async function findAssignees(parsedTaskAssignees: string[], projectId: number):
export const useTaskStore = defineStore('task', () => {
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const attachmentStore = useAttachmentStore()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
@ -205,7 +203,6 @@ export const useTaskStore = defineStore('task', () => {
}
kanbanStore.setTaskInBucketByIndex(newTask)
}
attachmentStore.add(attachment)
}
async function addAssignee({

View File

@ -7,21 +7,14 @@ import {useTitle} from '@vueuse/core'
import ProjectService from '@/services/project'
import ProjectModel from '@/models/project'
import type {IProject} from '@/modelTypes/IProject'
import type {IWebhook} from '@/modelTypes/IWebhook'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import WebhookManager from '@/components/misc/WebhookManager.vue'
import {useBaseStore} from '@/stores/base'
import type {IWebhook} from '@/modelTypes/IWebhook'
import WebhookService from '@/services/webhook'
import {formatDateShort} from '@/helpers/time/formatDate'
import User from '@/components/misc/User.vue'
import WebhookModel from '@/models/webhook'
import BaseButton from '@/components/base/BaseButton.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FormField from '@/components/input/FormField.vue'
import Expandable from '@/components/base/Expandable.vue'
import {success} from '@/message'
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
defineOptions({name: 'ProjectSettingWebhooks'})
@ -30,9 +23,6 @@ const {t} = useI18n({useScope: 'global'})
const project = ref<IProject>()
useTitle(t('project.webhooks.title'))
const showNewForm = ref(false)
const showBasicAuth = ref(false)
async function loadProject(projectId: number) {
const projectService = new ProjectService()
const newProject = await projectService.get(new ProjectModel({id: projectId}))
@ -49,75 +39,35 @@ const projectId = computed(() => route.params.projectId !== undefined
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
const webhooks = ref<IWebhook[]>()
const webhooks = ref<IWebhook[]>([])
const webhookService = new WebhookService()
const availableEvents = ref<string[]>()
const availableEvents = ref<string[]>([])
const loading = ref(false)
async function loadWebhooks() {
webhooks.value = await webhookService.getAll({projectId: project.value.id})
availableEvents.value = await webhookService.getAvailableEvents()
// Initialize all events to false to avoid undefined modelValue errors
newWebhookEvents.value = Object.fromEntries(
availableEvents.value.map(event => [event, false]),
)
loading.value = true
try {
webhooks.value = await webhookService.getAll({projectId: project.value.id})
availableEvents.value = await webhookService.getAvailableEvents()
} finally {
loading.value = false
}
}
const showDeleteModal = ref(false)
const webhookIdToDelete = ref<number>()
async function handleCreate(webhook: IWebhook) {
webhook.projectId = project.value.id
const created = await webhookService.create(webhook)
webhooks.value.push(created)
}
async function deleteWebhook() {
async function handleDelete(webhookId: number) {
await webhookService.delete({
id: webhookIdToDelete.value,
id: webhookId,
projectId: project.value.id,
})
showDeleteModal.value = false
success({message: t('project.webhooks.deleteSuccess')})
await loadWebhooks()
}
const newWebhook = ref(new WebhookModel())
const newWebhookEvents = ref({})
async function create() {
validateTargetUrl()
if (!webhookTargetUrlValid.value) {
return
}
const selectedEvents = getSelectedEventsArray()
newWebhook.value.events = selectedEvents
validateSelectedEvents()
if (!selectedEventsValid.value) {
return
}
newWebhook.value.projectId = project.value.id
const created = await webhookService.create(newWebhook.value)
webhooks.value.push(created)
newWebhook.value = new WebhookModel()
showNewForm.value = false
}
const webhookTargetUrlValid = ref(true)
function validateTargetUrl() {
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
}
const selectedEventsValid = ref(true)
function getSelectedEventsArray() {
return Object.entries(newWebhookEvents.value)
.filter(([, use]) => use)
.map(([event]) => event)
}
function validateSelectedEvents() {
const events = getSelectedEventsArray()
selectedEventsValid.value = events.length > 0
}
</script>
<template>
@ -126,186 +76,12 @@ function validateSelectedEvents() {
:has-primary-action="false"
:wide="true"
>
<XButton
v-if="!(webhooks?.length === 0 || showNewForm)"
icon="plus"
class="mbe-4"
@click="showNewForm = true"
>
{{ $t('project.webhooks.create') }}
</XButton>
<div
v-if="webhooks?.length === 0 || showNewForm"
class="p-4"
>
<FormField
id="targetUrl"
v-model="newWebhook.targetUrl"
:label="$t('project.webhooks.targetUrl')"
required
:placeholder="$t('project.webhooks.targetUrl')"
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
@focusout="validateTargetUrl"
/>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
<BaseButton
class="mbe-2 has-text-primary"
@click="showBasicAuth = !showBasicAuth"
>
{{ $t('project.webhooks.basicauthlink') }}
</BaseButton>
<Expandable
:open="showBasicAuth"
class="content"
>
<div class="field">
<label
class="label"
for="basicauthuser"
>
{{ $t('project.webhooks.basicauthuser') }}
</label>
<div class="control">
<input
id="basicauthuser"
v-model="newWebhook.basicauthuser"
class="input"
>
</div>
</div>
<div class="field">
<label
class="label"
for="basicauthpassword"
>
{{ $t('project.webhooks.basicauthpassword') }}
</label>
<div class="control">
<input
id="basicauthpassword"
v-model="newWebhook.basicauthpassword"
class="input"
>
</div>
</div>
</Expandable>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.events') }}
</label>
<p class="help">
{{ $t('project.webhooks.eventsHint') }}
</p>
<div class="control">
<FancyCheckbox
v-for="event in availableEvents"
:key="event"
v-model="newWebhookEvents[event]"
class="available-events-check"
@update:modelValue="validateSelectedEvents"
>
{{ event }}
</FancyCheckbox>
</div>
<p
v-if="!selectedEventsValid"
class="help is-danger"
>
{{ $t('project.webhooks.mustSelectEvents') }}
</p>
</div>
<XButton
icon="plus"
@click="create"
>
{{ $t('project.webhooks.create') }}
</XButton>
</div>
<div
v-if="webhooks?.length > 0"
class="has-horizontal-overflow"
>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>{{ $t('project.webhooks.targetUrl') }}</th>
<th>{{ $t('project.webhooks.events') }}</th>
<th>{{ $t('misc.created') }}</th>
<th>{{ $t('misc.createdBy') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<td>{{ w.targetUrl }}</td>
<td>{{ w.events.join(', ') }}</td>
<td>{{ formatDateShort(w.created) }}</td>
<td>
<User
:avatar-size="25"
:user="w.createdBy"
/>
</td>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
/>
</td>
</tr>
</tbody>
</table>
</div>
<Modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteWebhook()"
>
<template #header>
<span>{{ $t('project.webhooks.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.webhooks.deleteText') }}</p>
</template>
</Modal>
<WebhookManager
:webhooks="webhooks"
:available-events="availableEvents"
:loading="loading"
@create="handleCreate"
@delete="handleDelete"
/>
</CreateEdit>
</template>
<style lang="scss" scoped>
.available-events-check {
margin-inline-end: .5rem;
inline-size: 12.5rem;
}
</style>

View File

@ -360,6 +360,7 @@
:edit-enabled="canWrite"
:task="task"
@taskChanged="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId"
@update:attachments="onAttachmentsUpdated"
/>
</div>
@ -627,7 +628,6 @@
<script lang="ts" setup>
import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted} from 'vue'
import {useRouter, useRoute, type RouteLocation, onBeforeRouteLeave} from 'vue-router'
import {storeToRefs} from 'pinia'
import {useI18n} from 'vue-i18n'
import {unrefElement, useDebounceFn, useElementSize, useIntersectionObserver, useMutationObserver} from '@vueuse/core'
import {klona} from 'klona/lite'
@ -636,6 +636,7 @@ import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {IProject} from '@/modelTypes/IProject'
import {PRIORITIES, type Priority} from '@/constants/priorities'
@ -673,7 +674,6 @@ import {scrollIntoView} from '@/helpers/scrollIntoView'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import {playPopSound} from '@/helpers/playPop'
import {useAttachmentStore} from '@/stores/attachments'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import {useProjectStore} from '@/stores/projects'
@ -700,14 +700,13 @@ const route = useRoute()
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const attachmentStore = useAttachmentStore()
const {hasAttachments} = storeToRefs(attachmentStore)
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
const authStore = useAuthStore()
const baseStore = useBaseStore()
const task = ref<ITask>(new TaskModel())
const hasAttachments = computed(() => (task.value.attachments?.length ?? 0) > 0)
const taskNotFound = ref(false)
const taskTitle = computed(() => task.value.title)
useTitle(taskTitle)
@ -795,8 +794,20 @@ const color = computed(() => {
const isModal = computed(() => Boolean(props.backdropView))
function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
return uploadFile(props.taskId, file, onSuccess)
async function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
const uploaded = await uploadFile(props.taskId, file, onSuccess)
if (uploaded.length > 0) {
onAttachmentsUpdated([...task.value.attachments, ...uploaded])
}
return uploaded
}
function onAttachmentsUpdated(attachments: IAttachment[]) {
task.value.attachments = attachments
kanbanStore.setTaskInBucket({
...task.value,
attachments,
})
}
const heading = ref<HTMLElement | null>(null)
@ -896,7 +907,6 @@ watch(
try {
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
Object.assign(task.value, loaded)
attachmentStore.set(task.value.attachments)
taskColor.value = task.value.hexColor
setActiveFields()

View File

@ -64,6 +64,7 @@ const caldavEnabled = computed(() => configStore.caldavEnabled)
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
const webhooksEnabled = computed(() => configStore.webhooksEnabled)
const navigationItems = computed(() => {
const items = [
@ -112,6 +113,11 @@ const navigationItems = computed(() => {
title: t('user.settings.sessions.title'),
routeName: 'user.settings.sessions',
},
{
title: t('user.settings.webhooks.title'),
routeName: 'user.settings.webhooks',
condition: webhooksEnabled.value,
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@ -41,6 +41,86 @@ const route = useRoute()
const now = new Date()
interface TokenPreset {
id: string
groups: Record<string, string[] | '*'>
}
const presets: TokenPreset[] = [
{
id: 'readOnly',
groups: {
'*': ['read_one', 'read_all'],
},
},
{
id: 'tasks',
groups: {
'tasks': '*',
'tasks_attachments': '*',
'tasks_assignees': '*',
'tasks_labels': '*',
'tasks_comments': '*',
'tasks_relations': '*',
'labels': ['read_one', 'read_all', 'create'],
'projects': ['read_one', 'read_all', 'views_buckets_tasks'],
'projects_views': ['read_one', 'read_all'],
'projects_views_tasks': ['read_one', 'read_all'],
},
},
{
id: 'projects',
groups: {
'projects': '*',
'projects_views': '*',
'projects_teams': '*',
'projects_users': '*',
'projects_shares': '*',
'projects_webhooks': '*',
'projects_buckets': '*',
'projects_views_tasks': '*',
'tasks': ['read_one', 'read_all'],
'teams': ['read_one', 'read_all'],
},
},
{
id: 'fullAccess',
groups: {
'*': '*',
},
},
]
function applyPreset(preset: TokenPreset) {
resetPermissions()
for (const [groupKey, permissions] of Object.entries(preset.groups)) {
if (groupKey === '*') {
// Apply to all groups
for (const group of Object.keys(availableRoutes.value)) {
applyPermissionsToGroup(group, permissions)
}
} else if (availableRoutes.value[groupKey]) {
applyPermissionsToGroup(groupKey, permissions)
}
}
}
function applyPermissionsToGroup(group: string, permissions: string[] | '*') {
if (permissions === '*') {
// Select all permissions in this group
selectPermissionGroup(group, true)
newTokenPermissionsGroup.value[group] = true
} else {
for (const perm of permissions) {
if (newTokenPermissions.value[group]?.[perm] !== undefined) {
newTokenPermissions.value[group][perm] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -334,6 +414,26 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<!-- Presets -->
<div class="preset-buttons mbe-4">
<label class="label">{{ $t('user.settings.apiTokens.presets.title') }}</label>
<div
class="is-flex"
style="gap: .5rem; flex-wrap: wrap;"
>
<XButton
v-for="preset in presets"
:key="preset.id"
variant="secondary"
type="button"
@click="applyPreset(preset)"
>
{{ $t(`user.settings.apiTokens.presets.${preset.id}`) }}
</XButton>
</div>
</div>
<div
v-for="(routes, group) in availableRoutes"
:key="group"

View File

@ -23,6 +23,9 @@
alt=""
>
</p>
<p>
{{ $t('user.settings.totp.confirmNotice') }}
</p>
<FormField
id="totpConfirmPasscode"
v-model="totpConfirmPasscode"
@ -145,8 +148,8 @@ async function totpEnroll() {
async function totpConfirm() {
await totpService.enable({passcode: totpConfirmPasscode.value})
totp.value.enabled = true
success({message: t('user.settings.totp.confirmSuccess')})
await authStore.logout()
}
async function totpDisable() {

View File

@ -0,0 +1,66 @@
<script lang="ts" setup>
import {ref, onMounted} from 'vue'
import {useI18n} from 'vue-i18n'
import Card from '@/components/misc/Card.vue'
import WebhookManager from '@/components/misc/WebhookManager.vue'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {UserWebhookService} from '@/services/webhook'
import type {IWebhook} from '@/modelTypes/IWebhook'
defineOptions({name: 'UserSettingsWebhooks'})
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.webhooks.title')} - ${t('user.settings.title')}`)
const service = new UserWebhookService()
const webhooks = ref<IWebhook[]>([])
const availableEvents = ref<string[]>([])
const loading = ref(false)
async function loadWebhooks() {
loading.value = true
try {
webhooks.value = await service.getAll()
availableEvents.value = await service.getAvailableEvents()
} finally {
loading.value = false
}
}
async function handleCreate(webhook: IWebhook) {
const created = await service.create(webhook)
webhooks.value.push(created)
}
async function handleDelete(webhookId: number) {
await service.delete({id: webhookId})
success({message: t('project.webhooks.deleteSuccess')})
await loadWebhooks()
}
onMounted(() => {
loadWebhooks()
})
</script>
<template>
<Card
:title="$t('user.settings.webhooks.title')"
:loading="loading"
>
<p class="mb-4">
{{ $t('user.settings.webhooks.description') }}
</p>
<WebhookManager
:webhooks="webhooks"
:available-events="availableEvents"
:loading="loading"
@create="handleCreate"
@delete="handleDelete"
/>
</Card>
</template>

50
go.mod
View File

@ -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

80
go.sum
View File

@ -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=

File diff suppressed because it is too large Load Diff

View File

@ -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 <ENTER> 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")
}
}

View File

@ -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 {

75
pkg/config/config_test.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}
}

View File

@ -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

11
pkg/db/fixtures/totp.yml Normal file
View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -14,16 +14,19 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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())
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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")
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

31
pkg/files/storage.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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())
}

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