Commit Graph
275 Commits
Author SHA1 Message Date
kolaente 0d14a05aca feat(projects): expand task counts on the project list
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.
2026-10-02 16:50:34 +02:00
kolaenteandTink d851612413 feat(api): read the current user's general settings on v2
A GET next to the existing PUT lets AutoPatch expose PATCH
/user/settings/general, so clients can change single settings without
re-sending the rest.
2026-10-02 09:47:32 +02:00
kolaente ecfbd5b7ec fix(routes): send and expose Retry-After on rate-limited responses
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.
2026-10-01 17:34:38 +02:00
kolaenteandTink 82a263cbe9 fix(caldav-tokens): honour page in the v2 token list
The handler returned every token on every page while reporting
total_pages, so paging through the list repeated the same rows.

Fixes #4001
2026-10-01 17:29:11 +02:00
kolaenteandTink 30c468b1d5 fix(bots): order the bot list by id so pages are stable
Without an ORDER BY the paged LIMIT/OFFSET query could return overlapping
or missing bots across pages.
2026-10-01 17:29:11 +02:00
kolaenteandTink 41bb5e1cfb fix(api-tokens): order token list by id before paging
Without an ORDER BY, LIMIT/OFFSET pages are not stable, so tokens could
repeat or go missing across pages.
2026-10-01 17:29:11 +02:00
kolaenteandTink 45001e8827 fix(webhooks): add stable id ordering to Webhook.ReadAll
Webhook.ReadAll paged with Limit/Offset but no ORDER BY, so the row
order (and thus page contents) was undefined across requests.
2026-10-01 17:29:11 +02:00
kolaente f5c68ac049 fix(bots): 404 unowned bots, require status on PUT, audit owner status changes
- 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
2026-10-01 15:27:34 +02:00
kolaente 76c906bb44 test(admin): make admin project search tests ParadeDB independent 2026-10-01 03:18:23 +02:00
kolaente c650f7ddfe feat(admin): filter and sort the admin project list 2026-10-01 03:18:23 +02:00
kolaenteandTink 49bdd5f160 fix(sessions): break last_active ties by id so pages stay stable
Sessions sharing last_active could swap between LIMIT/OFFSET pages.
2026-10-01 00:06:26 +02:00
kolaenteandTink 94791debe4 fix(bots): allow owners to manage disabled bots 2026-09-30 10:42:35 +02:00
kolaenteandTink e61cf15617 refactor(auth): migrate session operations to the v2 SDK 2026-09-30 10:42:33 +02:00
kolaente eba8a33d81 feat(auth): return error code 16005 when the refresh cookie is missing
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
2026-09-29 22:54:20 +02:00
kolaente 277c97f682 fix(api): respect service.maxitemsperpage in v2 list endpoints
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.
2026-09-21 18:23:31 +02:00
kolaente d341e88574 test(ratelimit): align basic auth rate limit webtests to a fresh window 2026-09-18 15:59:07 +02:00
kolaenteandTink e06345e2d3 fix(migration): run file imports in the background
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.
2026-09-15 16:59:37 +02:00
kolaenteandTink d390a7c374 feat(mcp): expose connection settings and token presets
(cherry picked from commit a796db73475469bba18e3c20c8d7d1b818abcc5a)
(cherry picked from commit 0e47ccb001b8bf385f2688126bef8f4918917b45)
2026-09-15 11:41:34 +02:00
kolaenteandTink 84e5d2cd62 feat(routes): serve the MCP endpoint at /api/v2/mcp
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.
2026-09-15 11:41:34 +02:00
kolaenteandTink 0bb5a01845 feat(api-tokens): add the mcp:access scope and permission helpers 2026-09-15 11:41:34 +02:00
kolaenteandTink d9e581d62b refactor(api-v2): rename the projecttask path parameter to task
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.
2026-09-15 11:41:34 +02:00
kolaente 27ccdba7a0 feat(invites): add admin-managed user invite links 2026-09-12 18:21:35 +02:00
kolaente c0545eb559 feat(api): redirect historical task indexes on v2
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.
2026-09-11 20:47:11 +02:00
kolaente b796e9f840 test(api-tokens): cover sha256 fast path, legacy backfill and null legacy columns 2026-09-06 20:29:27 +02:00
kolaente 8ba3bbdf4a fix(errors): update the attachment size webtest and yaegi symbols for the moved codes 2026-09-06 00:12:30 +02:00
kolaente cb8c9fe303 fix(migration): treat a truncated import file as a client error too
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.
2026-09-05 20:24:56 +02:00
kolaente a0ab6070eb fix(migration): return 400 for unparseable import files
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
2026-09-05 20:24:56 +02:00
kolaente 98235c670c perf(tasks): index (project_id, done, due_date) instead of (done, due_date)
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.
2026-09-04 10:32:56 +02:00
kolaenteandTink a1aaafa20e feat(api): generate canonical v2 frontend client 2026-08-31 20:10:49 +02:00
kolaente 7b4b6046ca fix(migration): bound vikunja file imports
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.
2026-08-31 18:37:03 +02:00
kolaente c41a04d811 fix(migration): cap csv rows while decoding 2026-08-31 18:37:03 +02:00
kolaente 7fafe3738b fix(routes): rate limit basic auth failures 2026-08-31 18:37:03 +02:00
kolaente 40d95182fc fix(images): bound decode and resize dimensions
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.
2026-08-31 18:37:03 +02:00
kolaente 50b68e1d3d fix(auth): hide enabled totp secrets 2026-08-31 18:37:03 +02:00
kolaente 5a688ec17e fix(sharing): restrict team attachment visibility 2026-08-31 18:37:03 +02:00
kolaente e216a8c0e7 fix(sharing): restrict link share hash reads 2026-08-31 18:37:03 +02:00
kolaente b3d7c28f88 fix(users): reject link shares from user listings 2026-08-31 18:37:03 +02:00
de68a81af2 feat(admin): port GET /admin/users to /api/v2 (#3676)
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>
2026-08-31 09:33:47 +02:00
kolaenteandTink 5db6b0f4b9 perf(labels): list a user's labels without joining label_tasks
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.
2026-08-30 17:04:35 +02:00
kolaente 61d1c056f0 test(subscriptions): cover the opt-out paths and the rows they leave behind
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.
2026-08-29 23:42:41 +02:00
kolaente d1bf6c8d1a feat(subscriptions): allow unsubscribing from an inherited subscription
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.
2026-08-29 23:42:41 +02:00
kolaente 170a5b7011 refactor(bots): take a verified user, not any web.Auth
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.
2026-08-29 23:29:46 +02:00
kolaente 86957c1bbd fix(labels): share labels between all bots of the same owner
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.
2026-08-29 23:29:46 +02:00
kolaente 9c0fa73f3a feat(user): add shared bot identity helpers
IsBotOwnedBy and SameBotIdentityCond replace the ad-hoc bot ownership checks
scattered across labels and api tokens.
2026-08-29 23:29:46 +02:00
Melroy van den BergandGitHub ec7f020e15 feat(notifications): add clearing stored notifications (#2735) 2026-08-29 21:22:43 +00:00
kolaenteandTink d11ef378e2 test(projects): assert search stays within the accessible set
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.
2026-08-29 22:51:44 +02:00
kolaenteandTink e8765c7220 test(routes): cover the v2 unauthenticated rate limit 2026-08-29 21:47:07 +02:00
kolaenteandTink 4dbdc64053 test: merge overlapping refresh rate limit subtests 2026-08-29 21:47:07 +02:00
kolaenteandTink 4864678f70 test: cover the v2 admin license gate
address pr-swarm finding: the pathScoped refactor routes both admin gate
halves, but only the instance-admin half was covered on v2.
2026-08-29 21:47:07 +02:00
kolaenteandTink e955522590 test: assert both refresh cookie paths and consolidate the cookie helpers 2026-08-29 21:47:07 +02:00