Pass expand=task_counts to get each project's done and undone task
counts. The v2 expand param is now repeatable so it combines with
expand=permissions.
Every 429 from the rate limiters now carries Retry-After so clients know
when the window ends. The basic-auth limiter keys on aligned buckets, so
its value comes from the bucket end. Retry-After isn't CORS-safelisted,
so the CORS config exposes it for the desktop app and frontends on
another origin.
- Owners can manage bots in any status (disabled or locked); unowned,
missing and non-bot ids all return 404 so existence never leaks.
- PUT /api/v2/user/bots/{bot} requires status, so a partial body can no
longer re-enable a disabled bot. A bot_status validator tag limits it
to active/disabled.
- BotUser.Status is a pointer: the v1 update route keeps the current
status when the body omits it instead of unlocking the bot.
- Owner status changes dispatch bot.status.changed for the audit log.
- Regenerate the frontend client and adapt the bot update callers.
Refs #3999
Both refresh endpoints answered a missing cookie with a code-less 401, so
clients couldn't tell it apart from a rotated-away token, which is worth one
retry. ErrNoRefreshToken gives it its own code.
Refs #4023
ListParams defaulted per_page to 50 and allowed up to 1000 regardless of the instance setting. per_page now defaults to and is silently capped at service.maxitemsperpage, like /api/v1, and the response envelope reports the effective value.
A file import ran inside the upload request. A big export takes minutes,
far longer than a reverse proxy holds a request open, so the client got a
502 while the import kept running and kept the user's migration slot, and
there was no way to cancel or retry it.
The upload request now only validates what is cheap to validate, claims
the slot, spools the upload into a per-process temp directory and queues a
FileMigrationRequestedEvent. FileMigrationListener runs the import through
the lifecycle it now shares with the OAuth migrations: stale event skip,
panic recovery, heartbeat, outcome and notifications. The vikunja-file
migrator rejects a non-zip or structurally broken export before queueing;
ticktick, wekan and csv would have to parse the whole upload, so they fail
in the job and report through the status and failure mail. The csv
migrator receives its column mapping as options carried with the event.
Registered after apiv2.RegisterAll so the tool index sees the AutoPatch
operations. Routes under the prefix skip the token route check: the
endpoint checks mcp:access itself and every loopback call is checked on
its own route.
The v1 name leaked into the v2 OpenAPI document and everything derived from
it. Every other /tasks/... route already uses {task}. The expand-scope check
is keyed by echo path template, so its entry moves too, and a webtest now
fails if any of its keys stops naming a registered route.
A by-index address that no longer resolves falls back to the alias table
and answers 307 with the task's current address and Cache-Control:
no-store. The fallback requires read access to the project in the URL and
to the target task, using the same 403 body as DoReadOne.
encoding/json reports a document that ends mid-value as io.ErrUnexpectedEOF
rather than a *json.SyntaxError, so those still reached the error handler as a
500.
A WeKan JSON export saved with a UTF-8 BOM made encoding/json return a raw
*json.SyntaxError from the migrator, which the shared error handler turned into
a 500 and forwarded to Sentry. Map decode failures of the uploaded file to a
400 in RunFileMigration (shared by v1 and v2), and skip a leading BOM so an
otherwise valid export imports instead of being rejected.
Fixes API-CLOUD-4B
The overview's task query filters `project_id IN (...) AND done = false` and
sorts by due_date. `done` is not selective, so with an index on
(done, due_date) the planner walks it in due_date order and heap-probes every
open task in the database just to test project_id.
Leading with project_id lets the scan seek to the user's projects and top-N
sort a small set instead.
The old index is dropped rather than kept: its only other consumer is the daily
overdue-reminder cron, whose selective predicate is the due_date range that
IDX_tasks_due_date already covers.
ProjectID moves above Done in the Task struct because xorm orders composite
index columns by struct field order, which also shifts project_id earlier in
the serialized JSON — hence the churn in the task collection test's expected
response bodies.
Preflight over the declared uncompressed sizes (overflow-safe), a file
count cap and a per-user storage quota bound the import before
anything is read; the actual decompressed bytes are still counted
against the same budget while reading. Attachments and backgrounds
now stream through a lazy provider: one zip entry is decompressed
into a private temp file at a time. A failed import deletes the file
blobs it wrote, which were orphaned for every migrator before.
Decoding now validates image dimensions before allocating pixels
(imaging.Fit replaces unbounded Resize so neither side can blow up),
backgrounds decode once and reuse the image for the blur hash, and
DownloadImage is bounded at the configured max file size. Small sources
now keep their native size (Fit does not upscale); the frontend scales
via CSS.
The v2 admin API has every user action (create, promote, status,
password, delete) but not the list itself — the admin users view still
had to call v1 for `GET /admin/users`. This blocks moving that view onto
a generated v2 client.
The list query and its audit event move from the v1 route layer into
`models.ListUsersAsAdmin` (next to the other `*AsAdmin` actions); v1
delegates to it, v2 registers `admin-users-list` on top.
## How to verify
1. As an instance admin on a licensed instance, run `curl -H
"Authorization: Bearer $TOKEN"
https://instance/api/v2/admin/users?q=user2`
2. **Expected:** a paginated envelope (`items`, `total`) of users
matching "user2" in username or email, including the admin-only
`is_admin` and `status` fields, and an `admin.users.listed` entry in the
audit log.
3. As a non-admin user, request the same URL.
4. **Expected:** 404.
**Before this PR:** `/api/v2/admin/users` returned 404 for everyone;
only v1 had the list.
---------
Co-authored-by: kolaente <k@knt.li>
GET /labels ran the label list as `labels LEFT JOIN label_tasks ... WHERE
label_tasks.task_id IN (SELECT id FROM tasks WHERE project_id IN (<every
accessible project>)) GROUP BY labels.id`, fanning every label out over its
label_tasks rows only to collapse them again, and then repeated the whole
thing as a `count(DISTINCT labels.id)`.
The label list is now a plain select over labels whose task branch is an
uncorrelated `labels.id IN (SELECT label_id FROM label_tasks INNER JOIN
tasks ...)` subquery. The count query only runs when the returned page is
full, since a short page means the offset plus what we got is already the
total. The same applies to the per-task query when it is unpaginated, which
is how task lists load their labels.
The old query resolved its projects through getRawProjectsForUser, which
leaves out archived projects, so a label used only on tasks in an archived
project was missing from the list even though CanRead - and therefore
GET /labels/{id} - happily returned it. Reading the accessible projects
from the session access memo drops that accident and makes the list agree
with the permission check.
Covers unsubscribing a task that has both its own subscription and a subscribed
project, unsubscribing after lifting an opt-out, an opt-out outliving the
subscription it was made against, being assigned not lifting one, and removing
an own row after losing access leaving no opt-out behind.
Assertions pin the stored rows rather than only how a subscription resolves, so
a stray or duplicated row fails, and the read-access case now needs the
permission check to pass - it previously stayed green without it. The v2 test
decodes the task instead of matching substrings against the whole body.
Deleting a subscription that resolves through a parent project used to delete
nothing and fail the permission check, leaving the user subscribed with no way
out.
Delete now follows one rule - leave the entity resolving to unsubscribed. It
drops every row the user holds for the entity, re-resolves, and stores an
opt-out only when an ancestor is uncovered, so an own row shadowing a parent can
no longer resurrect the subscription either. The opt-out is written only for an
entity the caller can still read, while removing your own row stays possible
after losing access. Subscribing again lifts the opt-out and refreshes its
timestamp.
Muted is kept out of request bodies: json:"-" does not cover echo's XML binder,
which matches exported field names.
Both helpers compared a.GetID() against users.id, but *LinkSharing also
satisfies web.Auth and its GetID() is a link_shares.id - a different id
namespace. Passing one would have emitted users.id = <share id> and lent
that share another user's identity, which is the confusion behind
GHSA-vvcv-vpph-h844. Guarded at every call site, but the helpers moved
out of models and into user, so the guard drifted away from the code
needing it. Taking *User makes the case unrepresentable.
Also fail closed on a zero id: humans store bot_owner_id = 0, so an
unguarded zero would have matched all of them.
The doc claimed JWT and CalDAV auth carry no BotOwnerID, but bots can
reach neither path, and claimed bots-cannot-own-bots as an invariant when
the guard is inert for JWT principals. Reworded to chain-safety by
construction, which holds regardless.
The label access condition covered owner-to-bot and bot-to-owner, so two bots
of the same human could not use each other's labels: bot A seeded a label and
bot B got a 403 on it.
Replacing it with SameBotIdentityCond resolves every caller to an identity root
- a bot's owner, or a human themselves - and matches everyone under that root,
which covers all three directions in a single condition. Write access is
unchanged: only the creator and the human owner can update or delete a label.
Search now matches inherited projects, so the old counts are wrong: ILIKE returns
11 for Test1 where it returned 6, and ParadeDB's fuzzy match returns whatever it
returns — a number nobody can verify without ParadeDB running.
Assert the property the change is about instead. Nothing outside the user's
accessible set may come back, and the ids the substring match pins must be there.