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
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
`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>
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.
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.
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
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.
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.
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.
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.
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.
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.