Commit Graph
575 Commits
Author SHA1 Message Date
kolaenteandTink 685c31a1ae refactor(attachments): cut comment noise
Address pr-swarm comment sweep.
2026-10-19 20:33:06 +02:00
kolaenteandTink c32b0e41f2 perf(keyvalue): unlink keys per scan page when deleting a prefix
Address pr-swarm finding: the legacy preview cleanup collected every key and freed them in one blocking DEL.
2026-10-19 20:33:06 +02:00
kolaente c9b6d29383 fix(openid): copy oauth2 config before setting per-request redirect URL
The memory keyvalue store only shallow-copies Provider, so Oauth2Config
was shared between the cache and all requests. Concurrent callbacks
overwrote each other's RedirectURL, causing intermittent login failures.
2026-10-19 17:51:16 +02:00
kolaente d617a7a807 fix(migration): escape checklist titles in trello and wekan imports
Checklist and item titles were concatenated raw into the task description
HTML, so titles containing < or & were mangled.
2026-10-19 17:33:57 +02:00
kolaente 13f7a89a09 fix(openid): reject non-http(s) end_session_endpoint
A provider's discovery document could advertise a javascript: URL as
end_session_endpoint, which the frontend then navigated to on logout,
running script on Vikunja's origin. Only absolute http(s) URLs are now
accepted, both when caching the discovered endpoint and when building
the logout URL. Invalid endpoints are dropped so logout stays local.
2026-10-19 16:58:05 +02:00
kolaente 9803f8687a fix(dump): log only env variable names on restore, not their values 2026-10-19 16:05:26 +02:00
kolaente 12ed4036c8 fix(unsplash): close pingback response body
The pingback discarded the response, leaking the body and its
connection on every pingback.
2026-10-19 14:50:05 +02:00
kolaenteandTink 33bd1c9afe feat(ldap): match users and groups by an immutable directory id
Add auth.ldap.attribute.id. When set (e.g. objectGUID on AD, entryUUID
on OpenLDAP), users and teams are matched by that attribute instead of
the login name and the group DN, so renaming or moving them in the
directory no longer creates duplicate accounts or new teams without the
old shares.

AD's binary objectGUID is formatted as a canonical GUID string; other
attributes must be printable text. Existing accounts and teams are
switched to the new id on the next login. An empty option keeps the old
behaviour.
2026-10-19 13:42:19 +02:00
kolaenteandTink a2875739e9 fix(ldap): match legacy group members against the ldap username
Address pr-swarm finding.
2026-10-19 13:42:19 +02:00
kolaenteandTink eff486337f feat(ldap): query only the user's groups during group sync
Support {userdn} and {username} placeholders in auth.ldap.groupsyncfilter.
When present, every matching group is a membership and the member
attribute is not fetched, so the cost scales with the user's own
memberships and nested AD groups can be resolved with
LDAP_MATCHING_RULE_IN_CHAIN. Filters without a placeholder keep the
previous client-side member matching.

The group search now uses paged results in both modes so directories
with more groups than the server's size limit no longer fail the login.
2026-10-19 13:42:19 +02:00
kolaenteandTink 413b5bead2 refactor(ldap): use go-ldap filter escaping for the user filter
Address pr-swarm finding.
2026-10-19 13:42:19 +02:00
kolaente b3055f393c feat(mcp): point filter params, errors and instructions at filter_syntax 2026-10-19 13:25:00 +02:00
kolaente da2d3b01f0 feat(mcp): add filter_syntax tool and resource 2026-10-19 13:25:00 +02:00
kolaente 3557e172b4 feat(mcp): let read tools return only requested fields 2026-10-18 12:33:03 +02:00
kolaente f2d84ca6fc fix(migration): decode utf-16 and drop nul bytes in csv imports
A CSV saved as UTF-16 (Excel's "Unicode Text") was read as UTF-8, leaving
a NUL between every character. MySQL and SQLite store that, Postgres text
columns reject it with SQLSTATE 22021 and the whole import fails.

The file is now decoded to UTF-8 when read: a UTF-16 BOM switches to
UTF-16 decoding, remaining NUL bytes are dropped and invalid UTF-8 becomes
U+FFFD, matching what encoding/json does for the JSON importers.

Fixes API-CLOUD-4G
2026-10-13 21:45:53 +02:00
kolaente c7ff79c99c refactor(migration): read csv imports through one helper
Detect, preview and migrate each repeated the size checks and file read.
Extracting readCSVFile gives the raw bytes a single entry point, and
projectsFromCSV makes the migrate conversion testable without a database.
2026-10-13 21:45:53 +02:00
kolaente f99110c7d2 fix(auth): send the refresh cookie with SameSite=Lax
Over https the refresh cookie used SameSite=None. CORS allows localhost
origins by default for the desktop app, so a page on any localhost port
could refresh a session with the user's cookie and read the new token.

The cookie is now always SameSite=Lax. SameSite=None existed for the
desktop app's cookie refresh, which it replaced with OAuth over IPC in
v2.3.0.

Changelog:
- Desktop v2.2.x has to log in again more often; upgrade to v2.3.0 or later.
- Deployments with the frontend and API on different registrable domains
  lose cookie refresh, as before v2.2.0.
2026-10-02 19:08:02 +02:00
kolaente 46ae983554 fix(websocket): check the session and close sockets on revocation
The socket only checked the token's signature and expiry. A token of a
deleted session or of a disabled user still authenticated, and an open
socket kept receiving notifications after logout, session revocation,
password change or account deletion.

Auth now requires the token's session to exist for that user and the
user to be active; the connection registers first and is checked once,
so a concurrent revocation can't slip in between. Transient errors close
with try-again-later instead of invalid_token. Each socket closes when
its token expires, which bounds exposure even on nodes that miss a
revocation event.

Deleting sessions dispatches SessionsRevokedEvent on commit, every caller
flushes it after committing, and the hub closes the matching sockets.
Disabling or locking a user and deleting a user revoke their sessions,
also from the CLI.
2026-10-02 19:08:02 +02:00
kolaenteandTink 1bf93c58aa refactor(utils): rename NewHTTPClient to NewUnguardedHTTPClient
Address review comment: make the missing SSRF guard obvious at the call site
2026-09-30 18:26:56 +02:00
kolaenteandTink bff05f2377 fix(utils): stop exempting direct requests to the proxy address from the SSRF guard
The proxy dial exemption was keyed on addresses learned lazily from earlier
proxied requests, so a direct request (loopback, NO_PROXY or an unproxied
scheme) or a redirect to the proxy's host:port was dialed unguarded. Derive
the exempt addresses once from the configured or environment proxy URLs and
reject direct requests to them with ssrf.ErrProhibitedIP.

Address pr-swarm finding: SSRF guard bypass via the proxy address
2026-09-30 18:26:56 +02:00
kolaenteandTink 0909a337d3 fix(migration): route Trello API requests through the outgoing client 2026-09-30 18:26:56 +02:00
kolaenteandTink 3ddc89e2c5 feat(auth): route OIDC requests through the outgoing proxy 2026-09-30 18:26:56 +02:00
kolaenteandTink e61cf15617 refactor(auth): migrate session operations to the v2 SDK 2026-09-30 10:42:33 +02:00
kolaente cf92339788 fix(auth): add an iat claim to issued JWTs 2026-09-29 17:12:42 +02:00
kolaente 739d364f40 test(auth): require an iat claim on issued JWTs 2026-09-29 17:12:42 +02:00
06c451da40 fix(auth): link OIDC users by email when username fallback misses (#4012)
Both `usernamefallback` + `emailfallback` on: lookup required username
AND email match, so local user with different username never linked,
duplicate account created. Now verified-email-only lookup always tried
last.

Fixes #2022

## How to verify

1. Create a local user `localuser` with email `me@example.com`.
2. Configure an OIDC provider with `usernamefallback: true` and
`emailfallback: true`. In the IdP, create a user whose email is
`me@example.com` (sent with `email_verified: true`) and whose
username/sub is different from `localuser`.
3. Log in to Vikunja through that OIDC provider.
4. **Expected:** You are logged in as `localuser`, and `vikunja user
list` shows no new user for the provider's issuer.

**Before this PR:** a second user with a random or IdP-provided username
was created.

---------

Co-authored-by: kolaente <k@knt.li>
2026-09-24 06:27:43 +00:00
kolaenteandTink 80a9f31cb3 fix(migration): remove the partial blob of a failed import upload write
The files row is inserted before the blob is written, and the local backend creates the file before copying into it, so a failed copy left a partial blob behind once the row was rolled back.
2026-09-15 17:57:00 +02:00
kolaenteandTink c5685e6130 fix(migration): keep a queued import upload out of the storage quota
The vikunja-file import checks the user's stored files against
migration.vikunjafile.maxuserstorage. The upload is now one of those
files, so an import counted its own zip against the quota. Exclude files
referenced as a migration upload.
2026-09-15 17:57:00 +02:00
kolaenteandTink fa9fc4e06f feat(migration): keep queued import uploads in the file storage
The upload was spooled to the receiving instance's temp dir and only its
name travelled with the event. That holds while events stay in-process,
but it ties the job to the instance that accepted the upload.

Store it through the files package on the configured backend and record
its id on the migration status, so the event carries only the status id
and any instance can run the import. The job copies it to a local temp
file first: the zip importer needs random access, which an S3 object body
cannot give.

The upload is removed once the import is over. An hourly cron deletes
leftovers from events lost on restart or instances that died mid-import.
2026-09-15 17:57:00 +02:00
kolaenteandTink 9c374843af refactor(migration): tidy the ticktick, wekan and csv read paths
Drop comments restating the code and inline single-use variables. No
behaviour change.
2026-09-15 16:59:37 +02:00
kolaenteandTink 3dc5c72904 refactor(migration): parse the csv import config once per request
The config was unmarshalled up front purely to produce a 400, then parsed
again by SetOptions, and a third time in the background job. Drop the
throwaway parse and give SetOptions a domain error that maps to 400 on
its own.
2026-09-15 16:59:37 +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 c7b37b0af8 refactor(migration): scan the vikunja-file archive once before importing
Split opening the zip and indexing its entries out of Migrate, so every
structural check that doesn't need entry contents runs in one place before
anything is imported. The missing VERSION check moves into that scan.
2026-09-15 16:59:37 +02:00
kolaenteandTink 84804e71f5 feat(migration): record whether a migration succeeded
A finished migration_status row looked the same whether the import worked
or failed, so the client could only guess. Rows now carry error_kind, a
closed set the client translates (reported, interrupted, credentials,
queue, upload, detail), and error_message, set only for "detail": the
error caused by the user's own data, the same text the failure mail sends.
A failure we report to Sentry stores just the kind, so server internals
never reach the status.
2026-09-15 16:59:37 +02:00
kolaenteandTink b5a235b0fd fix(migration): don't report a failed success mail as a failed migration
The migration was already finished and its claim released when the done
notification failed, yet the error was returned and the listener then sent
a failure mail and overwrote the finished row. Log it instead. The failure
mail's own log line also printed the migration error instead of the
notification error.
2026-09-15 16:59:37 +02:00
kolaenteandTink 7f9f3e091f fix(migration): log migration start and completion at info level
A background import that never finished left no trace in a default log
setup, since start and completion were only logged at debug.
2026-09-15 16:59:37 +02:00
kolaenteandTink 68ac9eaa99 fix(migration): judge an abandoned claim by a heartbeat, not by start time
The claim timeout was measured from started_at, so a long import either
kept a dead claim for a day (the old 24h default) or had its slot taken
over while still running. A running migration now refreshes heartbeat_at
at a tenth of the timeout, clamped to 1s..30s, and a claim only counts as
stale once its last beat is older than the timeout. Rows that never beat
fall back to started_at. The default timeout drops to 5m accordingly.

Also bind the stale cutoff in the configured timezone: xorm writes times in
the engine's zone but binds query args as-is, which broke the comparison on
SQLite.
2026-09-15 16:59:37 +02:00
kolaenteandTink 86289e36e3 fix(migration): answer 412 instead of 500 when a migration claim collides
A second import sent while the first claim was still being written could
fail the insert with whatever the driver reports under contention, which
surfaced as a 500. The claim now reads the running row before writing so a
losing request stays read-only, re-reads the live claim whenever the insert
fails, retries lock collisions a few times, and probes for a stale claim
before taking a write lock to release it.
2026-09-15 16:59:37 +02:00
kolaenteandTink d1ca12f3e0 fix(restore): restore empty json column values as NULL
SQLite dumps can contain empty strings in json columns (e.g. project_views.filter),
which PostgreSQL rejects with "invalid input syntax for type json".

Fixes #3895
2026-09-15 14:25:43 +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 bba48e40bc feat(mcp): add the module and streamable-http transport
mcp.New builds the tool index from the v2 document and serves a stateless
per-request server: JWTs are rejected, mcp:access is required, tools/list is
filtered by the caller's token, and cross-origin browser requests are
refused unless the CORS configuration trusts the origin.
2026-09-15 11:41:34 +02:00
kolaenteandTink 4ccf95bf83 feat(mcp): add catalog discovery and execution meta-tools
find_action lists the catalog actions the token authorizes, with a
description generated per request; do_action invokes one.
2026-09-15 11:41:34 +02:00
kolaenteandTink 9d57de26d0 feat(mcp): dispatch tool calls through the v2 handlers
Each call is replayed as an internal HTTP request with the caller's API
token, so route scopes, expand scopes and the audit trail are the real ones.
2026-09-15 11:41:34 +02:00
kolaenteandTink e115c375f2 feat(mcp): derive tool specs from the v2 OpenAPI document
Tools are built from the finished document: path and query parameters plus
the JSON body become one input schema, update tools take their schema from
the sibling PUT because AutoPatch strips $ref, and an explicit allow-list
decides which operations are typed tools, catalog actions, or hidden.
2026-09-15 11:41:34 +02:00
kolaenteandTink 92f7edf0a0 refactor(humabridge): export the echo-context accessor
Five packages carried their own copy of the EchoContextKey lookup.
2026-09-15 11:41:34 +02:00
kolaente b263affe93 fix(import): create imported tasks in one batch with preserved indexes
Top-level and related tasks are collected first and created in one batch
so preset indexes survive, related references resolve to the created
struct instead of a mutable id lookup, untitled relation stubs are skipped,
and positions and buckets remap through one old-id map.
2026-09-11 20:47:11 +02:00
kolaente 69c3989b15 fix(dump): rebuild project_ancestors after restoring a dump
After a wipe the init-schema path marks every migration as applied, so
the backfill never runs on the restored rows and every project would be
invisible.
2026-09-10 22:44:26 +02:00
a59dbc2842 docs: add swagger annotations for the OAuth 2.0 endpoints (#3837)
`POST /api/v1/oauth/authorize` and `POST /api/v1/oauth/token` are served
but missing from the generated OpenAPI docs: the handlers in
`pkg/modules/auth/oauth2server` carry no swagger annotations, so neither
path is in `swagger.json` — 126 paths, no `oauth` among them.

Found while building a third-party client. The API answers fine; the
docs say the endpoints don't exist, so a client that validates its calls
against them can't use OAuth at all.

Adds annotations for both handlers and regenerates `pkg/swagger`. No
behaviour change — comments only, plus generated output. `mage
generate:swagger-docs` produces 521 insertions and 0 deletions, so
nothing else in the spec moved.

Checked against a 2.5.0 instance: `OPTIONS` on both paths returns
`Allow: OPTIONS, POST`, and `POST /api/v1/oauth/token` with no grant
type returns code 17007 naming `authorization_code` and `refresh_token`.

One judgement call worth a look: I annotated the token endpoint `@Accept
json`, matching the comment on `TokenRequest` that v1 binds JSON and v2
takes the form-encoded body. In practice v1 binds a form body too —
`Content-Type: application/x-www-form-urlencoded` with `grant_type=nope`
returns the same 17007 — so if you'd rather document both, say the word
and I'll widen it.

The `/api/v2` routes are untouched — happy to cover those too if you'd
like them in.

AI-assisted: annotations drafted with Claude, reviewed and tested by me.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:05:20 +00:00
kolaente fe712620f7 perf(db): read handlers and token auth run without a transaction
db.NewSession begins a transaction, so every request holds one pooled
connection for its whole life: across the API's own work and every round
trip in between. At 20,000 simulated users that filled a 192-connection
pool with 170 backends idle in transaction while 13 were active, and the
database sat at 40 % CPU with p99 at 94 ms.

NewReadSession attaches the session memo but does not Begin; each statement
borrows a connection only for itself. Commit and Rollback are no-ops on it,
so DoReadOne, DoReadAll and ValidateAPITokenString keep their shape. A read
path that writes (position healing) now commits per statement.
2026-09-08 21:09:26 +02:00
Wouter BlancquaertandGitHub 3f079e7d6d fix(migration): preserve todoist task descriptions (#3819) 2026-09-08 08:23:34 +00:00