Commit Graph
1426 Commits
Author SHA1 Message Date
kolaenteandTink 94791debe4 fix(bots): allow owners to manage disabled bots 2026-09-30 10:42:35 +02:00
kolaenteandTink 1f8a45f6a3 test(bots): reproduce disabled bot management failures 2026-09-30 10:42:35 +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 44d137aff1 fix(labels): remove task associations when deleting a label
Label.Delete only deleted the row in `labels`. Since `label_tasks` has
no foreign key cascade and no soft-delete column, every association of
the deleted label stayed behind as an orphaned row.
2026-09-28 15:21:55 +02:00
kolaenteandTink 33142cd8e6 fix(saved-filters): delete by id instead of every loaded field
CanDelete loads the whole filter into the struct. xorm turns every non-empty
field into a delete condition, so a filters JSON that does not re-serialize
byte for byte silently matches nothing while still returning success.
2026-09-16 17:23:51 +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 88ac0edb82 refactor(api-tokens): share the bearer selection and usage audit
The MCP loopback needs the same rules the token middleware applies, so both
now call models.APITokenAuthorization and models.RecordAPITokenUse.
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 98ca0175a2 refactor(notifications): rename local that shadowed the mail package 2026-09-14 17:50:28 +02:00
kolaente 1e37dfb7ca fix(reminders): skip inactive assignees when collecting task users 2026-09-14 17:50:28 +02:00
kolaente 5888756cd8 test(reminders): exercise the subscriber pass in the IsAssignee test 2026-09-14 17:50:28 +02:00
kolaente 99f0dbe1c3 feat(reminders): split overdue mail into assigned and followed sections
Users subscribed to busy projects got every overdue task in one flat list.
Tasks the user is assigned to now appear under "Assigned to you", tasks
they merely created or follow under "Tasks you follow". A task appears
once; assignee wins over follower.

Resolves https://github.com/go-vikunja/vikunja/issues/3612
2026-09-14 17:50:28 +02:00
kolaente f846d87e66 feat(reminders): track whether a task user is an assignee 2026-09-14 17:50:28 +02:00
kolaente 27ccdba7a0 feat(invites): add admin-managed user invite links 2026-09-12 18:21:35 +02:00
kolaente 814f164e33 feat(tasks): allocate task indexes from the per-project counter
Indexes come from an atomic counter increment instead of MAX(index)+1, so
retired indexes are never reused. Client-supplied indexes are ignored;
import and duplication keep presets above the old high-water mark. Moving
a task records its old address as an alias; the task row is read under a
row lock so concurrent moves serialize. Task bucket inserts are chunked
since whole batches now go through one create.
2026-09-11 20:47:11 +02:00
kolaente 0fd60fc080 feat(tasks): add task index counters and historical aliases
Each project gets a monotonic high-water counter row, backfilled from the
highest existing task index, and an append-only alias table recording
retired (project, index) addresses. Project creation seeds the counter,
project deletion removes both.
2026-09-11 20:47:11 +02:00
kolaente 2e6670e08e perf(projects): resolve project access and ancestors through project_ancestors
The access query joins grants once against the closure table instead of
walking the tree with a recursive CTE, and GetAllParentProjects reads
the chain from it.
2026-09-10 22:44:26 +02:00
kolaente 73da1fe4b0 test(projects): insert raw test projects through one helper that writes their ancestors 2026-09-10 22:44:26 +02:00
kolaente 47a0d0134a feat(projects): maintain a project_ancestors closure table
One row per project and each ancestor including itself, so the tree
resolves with a join instead of a recursive walk. CreateProject inserts
the chain, UpdateProject re-links the subtree on a parent change and
Project.Delete drops the rows; the relink reads take FOR UPDATE so
concurrent reparents of overlapping subtrees serialize, and a project
without its self row fails loudly instead of silently losing access.
Descendant archiving reads from the table. RepairOrphanedProjects
rebuilds the whole table so drift and rows of deleted projects go away.
2026-09-10 22:44:26 +02:00
kolaente b4012f1d08 refactor(models)!: drop the unused GetProjectsByIDs
It duplicated GetProjectsMapByIDs' query without the memo and had no caller left besides the yaegi symbol export.
2026-09-08 20:28:52 +02:00
kolaente 07e99dca0b test(db): cover the session memo
A behind-the-back write from a second session makes a memo hit the only way
the loader tests can pass.
2026-09-08 20:28:52 +02:00
kolaente aaecfadda5 refactor(models): load tasks by id through the memoized GetTaskByIDSimple
Addresses pr-swarm finding.
2026-09-08 20:28:52 +02:00
kolaente 5baf839ded perf(db): memoize single-row task, project and user lookups per session
One GET /tasks/{id} loaded the task twice (Task.CanRead, then ReadOne), its
project twice and users by id several times, each a round trip.
GetTaskByIDSimple, GetProjectSimpleByID, GetProjectsMapByIDs,
user.GetUserByID and user.GetUsersByIDs now go through the per-session memo,
which drops itself as soon as that session writes anything.

db.Remember and db.RememberEach hold the get-or-fetch dance, mirroring
keyvalue.Remember; the two pre-existing memo sites in project_access.go use
them too. Callers get copies: Project.memoCopy re-allocates ParentProjectID,
and the user memo holds the raw row so the disabled/locked gate runs on
every hit rather than only on the first load.
2026-09-08 20:28:52 +02:00
kolaente e2fafd1317 fix(comments): stop returning the author's email when reading a single comment
ReadOne loaded the author with a raw Get, which keeps every column. The list endpoint already goes through getUsersOrLinkSharesFromIDs, which blanks the email and resolves link share authors; ReadOne now does the same.
2026-09-08 10:34:37 +02:00
kolaente c6983d7db5 fix(assignees): include public team members in project user search 2026-09-07 21:35:17 +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 9922b1e4f4 feat(api-tokens): verify tokens by indexed sha256 instead of pbkdf2
Every API-token request ran pbkdf2.Key(token, salt, 10000, 50, sha256):
~3 ms CPU per request. Tokens are 160-bit CSPRNG values, so a slow KDF
and salt add nothing; sessions.go already stores refresh tokens as
plain SHA-256 for the same reason. Lookup is now one indexed seek on
token_sha256, falling back to the legacy PBKDF2 scan only for rows
that have not been backfilled yet. A legacy match backfills the column
on its own autocommit session, since callers roll theirs back.
2026-09-06 20:29:27 +02:00
7c3b34f1bb perf(subscriptions): bind entity and user ids as parameters instead of literals (#3776)
`getSubscriptionsForEntitiesAndUser` built its two recursive-CTE queries
with the entity ids (`entityIDString`) and the user id (`sUserCond`)
interpolated into the SQL text. Every `GET /tasks/{id}` therefore sends
a statement PostgreSQL has never seen: the driver's prepared statement
cache cannot hit, and the planner runs on every call.

Measured on the performance-testing setup (pgx, 3000 simulated users):
the task variant of this CTE was planned once per call, 1.3 ms of
planning for 0.08 ms of execution, and it accounted for half of all
planning time on the database (`pg_stat_statements`: 1985 calls, 1985
plans, 2,558 ms planning vs 166 ms execution in a 75 s window).

## Change

Ids and the user id are bound as `?` parameters; the query text now only
varies with the number of ids, so it is prepared once per connection and
planned a handful of times before PostgreSQL switches to the generic
plan. Argument order follows placeholder order in each query, built up
with a small `add` helper next to the query. Semantics are unchanged;
`TestSubscription*` and `TestGetSubscriptionsForEntity*` pass.

## How to verify

1. Open a task and subscribe/unsubscribe to it and to its project;
notifications for comments and assignments still reach the subscribed
users.
2. On PostgreSQL with `pg_stat_statements.track_planning = on`: the
`WITH RECURSIVE project_hierarchy … t.id IN ($1)` row now shows `plans`
far below `calls`.

---------

Co-authored-by: kolaente <k@knt.li>
Co-authored-by: kolaente <hello@kolaente.de>
2026-09-06 08:25:32 +00:00
kolaente 1b8167d4cd perf(db): use the pgx driver for postgres
lib/pq sends every query as an unnamed prepared statement, so PostgreSQL
plans each statement from scratch. pgx via database/sql keeps a per-connection
statement cache and reuses named prepared statements instead, removing that
planning cost from the hot path.

xorm already ships a "pgx" driver mapping, so the switch is just the driver
name plus the blank import. The connection string format is unchanged: pgconn
parses the same URL and forwards unknown parameters (search_path) as runtime
parameters.

Named prepared statements do not work through a connection pooler in
transaction pooling mode unless it supports them (PgBouncer 1.21 and later,
with max_prepared_statements above 0). Those setups can raise that setting or
connect to PostgreSQL directly, which is no loss since Vikunja already pools
via database.maxopenconnections.

lib/pq was only used for QuoteIdentifier, which is now inlined.
2026-09-05 23:16:31 +02:00
kolaente fbe6e5eff3 fix(filters): keep date filter boundaries inside the driver's year range
Parsing the zero-date sentinel (`due_date > 0001-01-01 00:00`) in a
timezone east of Greenwich underflows into year 0 on the way to UTC. The
MySQL driver then refuses to encode the bound parameter with "year is not
in the range [1, 9999]: 0" and the whole task collection request fails.

The year clamp was gated on the MySQL dialect, so it only ran where the
dialect was already known at parse time and left other drivers storing BC
dates. Apply it unconditionally instead, and normalize the time entry
filter to UTC like the task filter already does.

Sentry API-OSS-2Q.
2026-09-05 20:24:08 +02:00
kolaente 72844bf715 fix: lock views before repairing task positions 2026-09-05 12:30:42 +02:00
kolaente a9a9945bc8 fix: lock project views before deleting a project's task positions 2026-09-05 12:30:42 +02:00
kolaente 33e82a956d fix: lock project views before writing task positions in saved filter sync 2026-09-05 12:30:42 +02:00
kolaente 7c11e71430 refactor: split deleteStaleFilterTasks into collect and delete 2026-09-05 12:30:42 +02:00
kolaente 2a0f16a967 fix: lock project views before writing task positions on update and delete 2026-09-05 12:30:42 +02:00
kolaente d20d4da867 fix: lock project views before writing task positions on create 2026-09-05 12:30:42 +02:00
kolaente 206975997f fix: fall back to full recalculation when updating a task position
updateTaskPosition returned ErrNeedsFullRecalculation raw when the
neighbours around the target position were too close for a localized
repair, surfacing as an HTTP 500 on POST /api/v1/tasks/{id}/position.
It now falls back to a full recalculation of the view, like the task
creation path and the CLI repair command already do.

Ref: Sentry API-CLOUD-1R
2026-09-05 12:17:02 +02:00
kolaente fe52d4e23e fix: return 404 instead of 500 when a stored file is missing from disk
A file row whose blob is gone from storage (Sentry API-OSS-50) made
LoadFileByID return the raw storage path error, which the error handler
turned into a 500 and reported to Sentry. Return ErrFileDoesNotExist
instead and update the callers that matched on the path error.
2026-09-04 23:36:53 +02:00
kolaente 8dcd71db73 fix: don't report failed webhook deliveries to sentry 2026-09-04 23:27:29 +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
kolaente fc78cd3ea5 perf(projects): store the parent of top-level projects as null
The recursive step of the project access CTE joins parent_project_id
against a project id, which implies NOT NULL. Storing root projects as
NULL instead of 0 lets a partial index cover only real children, so the
index scan walks a fraction of the rows.

MySQL has no partial indexes and keeps the existing full index.
2026-09-04 09:28:29 +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 ce5e03d4c0 fix(filters): bound expression complexity
Also fixes IsErrInvalidFilterExpression to use errors.As: the parse paths
return pointer errors while the checker asserted a value type, so an
unparsable saved filter hard-failed its view instead of being skipped.
2026-08-31 18:37:03 +02:00
kolaente 82fe213467 fix(api): enforce token scopes for task expansions
Existing tasks-only tokens that used expand=comments, expand=reactions or
expand=time_entries_count will start receiving 401; they need to add the
tasks_comments, reactions or time_entries read_all scope respectively.
2026-08-31 18:37:03 +02:00
kolaente 32b7fdd73e fix(tasks): validate position target views 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 2dc0eb78da fix(tasks): require access for favorites 2026-08-31 18:37:03 +02:00
kolaente 98362b24af fix(tasks): restrict subtask expansion by access 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