1358 Commits
Author SHA1 Message Date
kolaente 3111aad1df fix(models): skip validation of hydrated author fields and hide comment author emails
Whole-model validation recursed into Author/CreatedBy, so a user with an
unvalidated language setting could permanently break label, team and comment
updates for everyone touching their rows.
2026-08-29 02:10:17 +02:00
kolaente 1165876191 refactor(validation): share StructFieldErrors and fail closed on undecodable errors 2026-08-29 01:42:20 +02:00
kolaente a3930f6e68 fix(tasks): hide assignee emails in TaskAssginee.ReadAll
Every other user-bearing ReadAll obfuscates in the model; this one leaked
addresses over v1, v2 and MCP.
2026-08-29 01:42:20 +02:00
kolaente 00eb50b79c test(mcp): tighten catalog scope assertions and restore registry state after tests 2026-08-29 01:27:15 +02:00
kolaente f71fce7b99 refactor(mcp): drop the ToolSuffix alias and cut comment noise 2026-08-29 01:00:54 +02:00
kolaente 1adbae211e refactor(validation): move govalidator rules and struct validation into models 2026-08-29 00:48:55 +02:00
kolaente be37d944f1 refactor(api-tokens): share one HasPermission lookup across caldav, feeds and mcp 2026-08-29 00:36:12 +02:00
kolaente ee85b245da test(models): restore api token route maps after each test 2026-08-29 00:34:27 +02:00
kolaente 71634ba8c8 feat(mcp): expose expand on tasks_read_all 2026-08-29 00:05:16 +02:00
kolaente 17d52933d7 fix(mcp): appease lint and pin user1's fixture token set
TestHumaAPIToken pinned user1's owned tokens to {1,2}, broken since the
MCP fixture tokens 9-11 landed; the exact set now includes them.
2026-08-28 23:56:04 +02:00
kolaente 28ee6a2a58 feat(mcp): expose task listing through the TaskCollection filter engine
tasks_read_all accepts filter/sort_by/order_by (from TaskCollection's
query-tagged fields) plus the generic search/page/per_page arguments;
project_id is optional so agents can list across projects. Fixture token
11 gains the tasks.read_all scope it now exercises.
2026-08-28 23:54:56 +02:00
kolaente 21d7c79d5a feat(mcp): register mcp:access api token scope
Adds the mcp scope group with a single access permission so it shows up
in GET /api/v1/routes (and therefore in the frontend token form).
Adds APIToken.HasMCPAccess() mirroring the caldav/feeds helpers.

The MCP endpoint will use POST, GET, and DELETE on the same path for the
streamable-HTTP transport, which CanDoAPIRoute's exact (method, path)
match cannot gate. The token middleware therefore skips the route check
for /api/v1/mcp and any sub-path; the actual authorization is delegated
to an inline HasMCPAccess() call in the MCP handler (added in the next
task).

Fixtures gain two MCP tokens for user 1: one mcp-only and one with
mcp:access plus projects read scopes for the per-tool scope filter tests.
2026-08-28 23:53:24 +02:00
kolaente 78bb1213f7 test(projects): cover the parent owner on member-created subprojects
The scenario reported in #3574: a write member creates a subproject under a
project someone else owns. The parent's owner keeps admin on it, and CanRead,
IsAdmin and expand=permissions must all agree, for a direct share and a team
share alike. Both directions are covered — an unrelated user gets neither read
nor admin and does not see the subprojects listed, and a write member is not
reported as admin on the parent.
2026-08-28 23:49:27 +02:00
kolaente 9d1ac4c1d3 fix(permissions): decode a null permission as unknown, not read
MarshalJSON emits null for PermissionUnknown, but json.Unmarshal treats null as
a no-op for an int, so it decoded back as PermissionRead — the two did not round
trip.

Permission also backs the persisted permission on ProjectUser, TeamProject and
LinkSharing, where isValid now rejects an explicit "permission": null instead of
silently creating a read tier share. Omitting the key still defaults to read:
UnmarshalJSON is never called for an absent key.
2026-08-28 23:49:27 +02:00
kolaente aa5c5c7274 fix(projects): report read for the Favorites pseudo project
Favorites has no row, so checkPermissionsForProjects returns nothing for it and
expand=permissions left the field nil. checkReadPermissionsForProjects already
resolves it to read; the list route now agrees instead of reporting null.
2026-08-28 23:49:27 +02:00
kolaente dfb469741c fix(projects): report null for a max_permission nobody computed
Permission's zero value is PermissionRead, so Project.MaxPermission serialized
as 0 — a real permission meaning read — on every response path that never
resolved it. GET /api/v1/projects/:project claimed read-only access on a
project you own while the x-max-permission header correctly said 2.

Typing the field *Permission makes that unrepresentable: nil marshals to null,
so "not computed" is the default rather than something each call site has to
remember. That also fixes the paths which serialize a project outside the CRUD
pipeline and had the same lie — the admin project list, the background
handlers, duplicated_project on both API versions, and the admin owner-reassign
route — and retires v2's two explicit resets, which the field type now covers.

Webhook payloads for project.created and project.updated change from 0 to null
along with it.

Fixes #3574
2026-08-28 23:49:27 +02:00
kolaente d77e9d07bf fix(labels): return 403 instead of panicking on bulk label replace
A write-enabled link share clears LabelTaskBulk.CanCreate, which only
checks task write access. UpdateTaskLabels then rejects any label the
share cannot see and built the error with an unchecked type assertion to
*user.User, so creator.(*user.User) yielded nil and .ID panicked - a
clean 403 became a recovered panic and a 500.

Use creator.GetID(), matching how ErrNoPermissionToSeeTask is built a
few lines up.
2026-08-27 22:28:51 +02:00
kolaente d66ef3d1a3 feat(tasks): subscribe task creator to their own task
The creator is usually the most interested party of a task, but was not
notified about comments or changes unless they subscribed manually or got
@mentioned. Assignees have been auto-subscribed on assignment for a while,
so this makes creation consistent with that.

Subscriptions inherited from the project are resolved by Subscription.Create,
so no redundant task level subscription is created when the user is already
subscribed further up the hierarchy. Link shares can't hold subscriptions and
are skipped.

Fixes #2692
2026-08-27 22:00:32 +02:00
5576c8d4af fix(labels): let bots use labels created by their owner (#3609)
Label with zero task attachments unreachable by anyone but creator, so
label seeded by human unusable by that human's own bot — every attach
403s forever until someone else attaches it once.

Refs #3592

`hasAccessToLabel` grants non-creators access only through existing
`label_tasks` row on readable task. Bot owners already inherit access to
labels their bots created (c9c2c58c1); this adds missing reverse
direction so both sides of bot/owner pair share label access.

Human-to-human access unchanged: another user's labels stay invisible
until they show up on a task you can read — the restriction
GHSA-hj5c-mhh2-g7jq introduced. Bots inherit read/attach only; renaming
and deleting the owner's labels still requires being the owner.

### Scope: does not close #3592

Deliberately `Refs`, not `Fixes`. The issue also reports the
human-teammate case — someone you shared a project with still cannot
attach a label that has never been used. That stays broken here, because
the fix the issue proposes for it (allow attach whenever the caller can
write the target task) would let any user attach any label id to a task
they own and read the title back, re-opening GHSA-hj5c-mhh2-g7jq in a
new shape. Whether labels should become project-scoped or explicitly
shareable is a design question worth its own issue — leaving #3592 open
to track it.

Note: the commit trailer still reads `Fixes #3592`, so adjust the
message on squash-merge if you want the issue left open.

### Operator note: widened bot/owner scope

A bot token can now enumerate every label its owner has created via `GET
/api/v2/labels`, including labels only used on projects the bot was
never given access to, and can attach one to a task in any project it
can write (which then makes that label visible to that project's
members). Read-only — rename and delete still require being the owner.
Worth knowing if you hand bot tokens to third-party integrations.

## How to verify

1. As a human user, create a label and do not attach it to any task:
`POST /api/v2/labels {"title":"seeded"}` → note the returned id `N`.
2. Create a bot owned by that user (`PUT /api/v1/user/bots`), issue an
API token owned by the bot with the `tasks_labels` create scope, and
give the bot access to a project.
3. As the bot, create a task in that project, then attach the label:
`POST /api/v2/tasks/{taskID}/labels {"label_id": N}`.
4. **Expected:** the request returns 201 and the label appears on the
task. `GET /api/v2/labels` as the bot also lists label `N`.
**Before this PR:** step 3 returned 403 on every attempt, and label `N`
was missing from the bot's label listing, until some other user attached
the label to a task first.
5. As a second, unrelated user (not the bot's owner), attach the same
label to a task you can write. **Expected:** still 403 — the label
remains private to its creator until it is visible through a shared
task.
6. As the bot, try to rename and delete label `N` (`PUT` and `DELETE
/api/v2/labels/N`). **Expected:** 403 on both.

---------

Co-authored-by: kolaente <k@knt.li>
2026-08-27 21:55:14 +02:00
kolaente 64a5518511 feat(notifications): notify subscribers when a task is created
Subscribing to a project promised notifications "for changes", but no
listener was registered for TaskCreatedEvent beyond mention handling, so
new tasks never reached subscribers.

Adds a TaskCreatedNotification and a listener that notifies task and
project subscribers, skipping the creator and users already notified by
the mention listener for the same event.

Fixes #3611
2026-08-27 15:41:58 +02:00
emilsteixnerandGitHub 112a807d7d fix(teams): preserve members across list responses (#3486)
Fixes incomplete team `members` arrays when a user belongs to multiple
teams included in the same response.
`addMoreInfoToTeams` queried one row per `(team_id, user_id)` membership
but stored those rows in a map keyed only by user ID. When a user was a
member of multiple teams, a later row replaced an earlier one. The
subsequent member assignment loop therefore used only the surviving
membership row.
The fix stores all queried membership rows in a slice, preserving every
team-user relation. It creates a separate `usersByID` map only for
resolving `created_by`, where one row per user is appropriate.
This affects both list paths because both use `addMoreInfoToTeams`:
- `GET /api/v1/teams`
- `GET /api/v1/projects/{projectId}/teams`
2026-08-20 15:32:23 +00:00
kolaente 793ef8e349 fix(projects): tolerate dangling stored parent when updating, keep admin bypass for filter buckets
Un-archiving echoes the whole project incl. a parent_project_id that may no
longer exist; that nil-dereferenced in the cycle check. A newly requested
missing parent still errors.
2026-08-20 11:58:06 +02:00
kolaente ee2a12754b fix(projects): allow un-archiving orphaned projects, batch descendant archive update
An archived project whose stored parent no longer exists returned 404 on
un-archive. Reparenting under an archived parent now reports
ErrParentProjectIsArchived.
2026-08-20 11:58:06 +02:00
kolaente 374045e5bf fix(projects): sub-resource permission checks no longer get the un-archive exception
Buckets, webhooks, backgrounds and task duplication checked write access
through Project.CanUpdate on a bare stub, whose zero IsArchived matched the
un-archive carve-out. With is_archived now set on descendants too, that
made whole archived subtrees writable. Use CanWrite instead.
2026-08-20 11:58:06 +02:00
kolaente 103a5d4635 refactor(projects): dedupe effective parent lookup, tidy error ordering and comments
Also adds a negative control to the structure-import archive test.
2026-08-20 11:58:06 +02:00
kolaente 5732a435a5 fix(migration): cascade archived state to descendants on structure import
Exports written before archiving cascaded carry unflagged children under
archived parents; with the column now authoritative nothing heals that.
2026-08-20 11:58:06 +02:00
kolaente cc9dfe8e1b fix(projects): reject un-archiving a child while its parent is archived
Enforced in UpdateProject, the single write path, instead of CanUpdate,
which short-circuits for instance admins and is bypassed by direct
callers. Callers get a dedicated ErrParentProjectIsArchived (3016)
telling them to un-archive the parent first.
2026-08-20 11:58:06 +02:00
kolaente 23bea7b8c0 refactor(projects): read archived state from the materialized column only
is_archived is written down the whole subtree on archive/unarchive and
backfilled for pre-existing rows, so CheckIsArchived, ReadOne and the
project list CTE no longer need to derive it from ancestors. The list
CTE previously only saw ancestors the user could access, so list and
single reads could disagree. task_overdue_reminder already trusted the
column and is now correct for old data too.
2026-08-20 11:58:06 +02:00
kolaente 8fc923399b fix(projects): only cascade archived state to descendants when it changes
A plain edit of an unarchived parent (client sends is_archived=false)
no longer un-archives individually archived children.
2026-08-20 11:58:06 +02:00
kolaente 894964e2ee test(projects): materialize inherited archived state in fixtures
Project 21 now carries is_archived=1 like the backfill migration produces.
Un-archiving a project whose parent is still archived is rejected in
CanUpdate explicitly instead of relying on the unflagged child.
2026-08-20 11:58:06 +02:00
kolaente fc029c6af7 fix(projects): deny writes to pseudo projects across the permission family
Favorites and saved filters are exposed as projects under negative ids, but they
aggregate tasks that live in real projects and have no row of their own. Only
CanWrite knew that, and only for favorites, so every other permission method fell
through to GetProjectSimpleByID and errored on an id it can never resolve:

- IsAdmin ignored saved filters, so CanDelete - which delegates to it - answered
  "project does not exist" for a pseudo id instead of denying.
- CanUpdate delegated saved filters to the filter's own check, but only after the
  instance admin bypass, so an admin never reached the owner check.
- checkReadPermissionsForProjects took its admin branch ahead of the pseudo cases
  and then found no row, so CanRead errored for instance admins.
- CanDelete short-circuited on instance admin ahead of IsAdmin, reintroducing the
  same bypass.

Every one of them now returns a verdict rather than an error, and instance admins
get a real answer instead of a blanket yes. IsPseudoProjectID gives callers a name
for the id encoding they used to open-code.

CanCreate has the same shape for a negative parent_project_id, but changing it
would deny something that works today, so it is left alone.
2026-08-19 16:02:39 +02:00
kolaente e007a1da70 fix(api/v2): don't require read_one for autopatch's internal GET
Huma's AutoPatch implements PATCH as an internal GET + PUT re-dispatched through
the router, so both legs re-enter the API token middleware. Scoping the GET leg
like a client request made every /api/v2 PATCH additionally demand the
resource's read_one permission, so a token scoped to tasks: update could never
patch a task.

Mark the re-dispatched requests with the route the client request was matched
against, and skip the scope check only for a GET leg that carries no query
string and resolves to that exact route. Anything looser is exploitable: echo
routes on the raw path while autopatch re-dispatches the decoded one, so an
encoded slash steers the unchecked leg onto a deeper route and an encoded
question mark smuggles a query onto it.

Fixes #3528
2026-08-19 13:01:59 +02:00
kolaente 9aad78a420 fix(tasks): don't wrap reminders into the past when repeating from current date
Offsets between reminders spanning more than ~292 years overflowed
time.Duration, so the shifted reminder landed centuries in the past.
Shift dates via a helper that falls back to year arithmetic when
time.Time.Sub saturates, and stop sorting the reminders in place.
2026-08-18 19:01:05 +02:00
TinkandGitHub 2996902568 fix(notifications): include the project prefix in task identifiers (#3538) 2026-08-18 16:38:00 +00:00
kolaente 57493ef81e fix(projects): make position healing include the moved project
recalculateProjectPositions ran before the new position was written, so the
moved project kept its tiny position while all its siblings were renumbered.
Every further move to the top then triggered another recalculation, and root
projects with a NULL parent_project_id were never healed at all.
2026-08-18 14:28:37 +02:00
kolaente 3833b34d37 fix(user): keep email confirmation status after registration
The default-project creation triggered by the user-created event wrote the
stale in-memory user back with a full UpdateUser, racing registration and
sometimes resetting the status from "email confirmation required" back to
active. Only update the default_project_id column and keep the returned
user's status in sync with what was persisted.
2026-08-18 11:30:03 +02:00
kolaente 536d7b7dbf fix(notifications): retry subscriber notifications instead of dropping them on error
Since e25295422 the subscriber loops in the comment, assigned, deleted and
project-created listeners logged a failed Notify and moved on. On SQLite the
insert regularly fails with "database is locked" (SQLITE_BUSY_SNAPSHOT: the
listener's deferred transaction cannot upgrade to a write once a sibling
listener has committed), so the notification was silently lost.

Return the error again so the events router retries the handler with a fresh
session, as it did before 2.5.0.

Reported at https://community.vikunja.io/t/notification-problems-since-2-5-0/4750
2026-08-18 09:46:57 +02:00
kolaente 7d91052976 feat: show relative time in api token expiry notifications 2026-08-18 09:44:49 +02:00
kolaente 6d2b5aff54 fix: load export file meta inside transaction and open storage after commit
v1 queried on an already-committed session; v2 opened storage while
still holding the transaction. Both now load meta under the tx, commit,
then open the file. Addresses pr-swarm findings.
2026-08-16 17:57:52 +02:00
kolaente 96ddc10110 fix: release db connection before reading attachment files from storage
Preview generation reads the whole file (a network round trip on s3),
decodes and resizes it — seconds per image — while the request
transaction pinned a pool connection. A board full of image previews
could drain the pool. Commit first; the storage read needs no
transaction.
2026-08-16 17:57:52 +02:00
kolaente 044106508a fix: use caller's session in LoadFileMetaByID to prevent connection pool deadlock
Querying the global engine while the request already holds an open
transaction acquires a second pool connection. Under concurrent
attachment downloads (e.g. a kanban board full of image previews) all
pool connections end up held by transactions that each wait for an
extra connection that can never be freed, hanging every request until
restart.

The redundant meta load in the upload avatar provider is removed
entirely; the decoded image is all that path uses.
2026-08-16 17:57:52 +02:00
kolaente d545e3bbb1 refactor: drop redundant link share branch in comment permissions
GetID() delegates to getUserID(), so both branches compute the same value.
2026-08-04 13:40:11 +02:00
kolaente ed8dc89f75 fix: report the real link share id in saved filter errors
auth.GetID() is now negated, so ErrSavedFilterNotAvailableForLinkShare
carried a value that is not a link share id.
2026-08-04 13:40:11 +02:00
kolaente 4b35f5d7e2 test: assert link share attachments are not attributed to a colliding user
files.Create stores GetID() straight into files.created_by_id, so a share
uploading an attachment used to persist a row indistinguishable from one
created by the user with the same id.
2026-08-04 13:40:11 +02:00
kolaente ce0d1355cf fix(security): reject link shares in Webhook.ReadAll
The guard on Webhook.CanRead is unreachable: no route exposes a read-one
webhook, and DoReadAll never calls CanRead. Two paths were left open:

- the v2 user-webhook list passes a.GetID() into Webhook.UserID, which is
  negative for a link share, so the w.UserID > 0 branch and its link share
  check were skipped and the request fell through to the project branch with
  project id 0, returning 404 instead of 403.
- the project branch never rejected link shares at all, so any holder of a
  public share link could list the project's webhooks. target_url is a bearer
  secret for Slack, Discord, Teams and Zapier.

Guard both by rejecting link shares at the top of ReadAll.
2026-08-04 13:40:11 +02:00
kolaente a717d64d56 fix(security): reject link shares at team, bot and webhook permission checks
These checks all compare the auth id against a users.id column, so a link
share whose id collided with a user id passed them as that user.
2026-08-04 13:40:11 +02:00
kolaente 6ffe265efa fix(security): return the negated user id from LinkSharing.GetID
web.Auth is satisfied by both *user.User and *LinkSharing, so returning the
raw positive share.ID made a share with id N indistinguishable from the user
with id N at every permission check comparing against a users.id column.

The rest of the codebase already keys shares negatively
(getUsersOrLinkSharesFromIDs, toUser), so this makes GetID consistent with
that contract instead of an exception to it.
2026-08-04 13:40:11 +02:00
kolaente 2d8198e942 fix(views): seed and maintain kanban buckets when a view's kind changes
Switching an existing view to kanban left bucket_configuration_mode at
none, so the tasks endpoint returned a flat task list which the
frontend rendered as empty bucket columns.

The mode is now normalized on create and update: a kanban view without
a mode becomes manual, a non-kanban view loses its mode, and an update
which omits the mode keeps the stored one together with its bucket
configuration. Becoming a manual kanban view seeds the default buckets
and backfills task_buckets rows for tasks which have none in that view,
so tasks created while the view was of a different kind stay visible.
Bucket ids from the request are validated against the view: an id of a
bucket which is gone resets to zero instead of locking the view, an id
belonging to another view is rejected, and non-kanban views no longer
write those columns at all so a round trip can restore them.

The backfill selects only task ids, scopes saved filter views to the
projects their owner can see, batches its inserts and ignores conflicts
with concurrently placed tasks.

Fixes https://github.com/go-vikunja/vikunja/issues/3386
2026-08-04 01:09:10 +02:00
kolaente 57bfd1cae2 feat(api/v2): add bulk task creation endpoint
POST /api/v2/projects/{project}/tasks/bulk creates up to 100 tasks in
one atomic request. The URL project wins over any body project_id, and
rich-text descriptions honor the format query param like single create.
Bucket limits are only enforced for explicitly provided bucket ids.

The route files under the tasks API-token group as create_bulk,
matching the v1 bulk naming, so a token scoped to tasks can use it.
2026-08-02 16:21:50 +02:00
kolaente 42fdacd26d feat(tasks): update saved-filter views once per creation batch
The resource-heavy saved-filter evaluation moves from TaskCreatedEvent
to the batch event: filters, views and the fallback timezone are loaded
once per batch, then every task is evaluated in one pass. Buckets and
positions are inserted per task so a mid-loop position recalculation
sees earlier members' rows.
2026-08-02 16:21:50 +02:00