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.
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.
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.
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.
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.
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.
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
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.
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
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>
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
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`
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.
An archived project whose stored parent no longer exists returned 404 on
un-archive. Reparenting under an archived parent now reports
ErrParentProjectIsArchived.
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.
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.
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.
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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
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.
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.