Commit Graph
557 Commits
Author SHA1 Message Date
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
kolaente 912e899c10 perf(auth): reuse the api token owner from the request context
GetAuthFromClaims opened a session and loaded the token owner by id on
every call, although the token middleware had already put the user into
the context. With the metrics middleware and the handler both resolving
the auth, that was two extra transactions and user lookups per request
authenticated with an API token.
2026-09-06 16:40:57 +02:00
kolaente 7fa1c7db97 fix(sentry): group reported errors by the error, not the reporting frame
The api error handler, the poison queue logger and the migration listener all
captured through the same frame, so API-OSS-2P collected six unrelated MySQL
errors under one title. Set a derived fingerprint and an error.type tag on the
scope before capturing.
2026-09-06 10:51:30 +02:00
kolaente 93fa646485 fix(migration): return 400 instead of 500 for broken vikunja exports
Unsafe paths, unusable file ids, a missing or unparseable VERSION and
unreadable data/filter json all describe a broken upload, not a server
fault, but they were raw fmt.Errorf values and surfaced as a 500. They now
return ErrInvalidImportFile; an export from a version we can no longer
read gets its own ErrImportFromUnsupportedVersion (14013) so clients can
tell the two apart. The http messages stay generic and never echo a path
from the archive.
2026-09-06 00:12:30 +02:00
kolaente 4090a3de2a fix(migration)!: move zip/empty file error codes off the api token codes
ErrCodeNotAZipFile and ErrCodeFileIsEmpty shared 14001 and 14002 with
ErrCodeAPITokenInvalid and ErrCodeInvalidAPITokenPermission, so clients
mapping codes to messages rendered the api token text for a failed import.
The api token codes are the older ones, so the migration errors move to
14011 and 14012.

BREAKING CHANGE: a failed import now returns 14011 instead of 14001 and
14012 instead of 14002.
2026-09-06 00:12:30 +02:00
kolaente 4a7d927ac7 fix(migration): do not report 4xx domain errors to sentry
Errors like planka's ErrInvalidCredentials or ErrNoPlankaAtURL surface
inside the migration listener and map to a 4xx, so they are the user's
problem to fix, not ours.
2026-09-06 00:00:49 +02:00
kolaente 7d1aee8d0d fix(migration): use 14010 for ErrInvalidImportFile, 14008 is taken 2026-09-05 20:24:56 +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 d388b9d6b9 fix(migration): use 14009 for ErrNoDataFileInZip to avoid clashing with #3745 2026-09-05 11:58:40 +02:00
kolaente 74bb916f7c fix(migration): return 400 when an import zip has no data file 2026-09-05 11:58:40 +02:00
kolaente 8714294ac7 fix(migration): don't report upstream 4xx migration failures to sentry
A 4xx from the migration source (disabled mailbox, expired token, deleted
item) is not a bug on our side, but every one of them was captured as an
exception. Those users now get the failure notification with the upstream
message instead of the "we have been notified" one.
2026-09-05 11:55:07 +02:00
kolaente 86260fcdc0 feat(migration): typed error for failed upstream requests
Non-2xx responses from the service we migrate from were plain fmt.Errorf
values, so callers could not tell an upstream problem apart from a bug.
ErrUpstreamRequestFailed carries the migrator, status code and a truncated
body and is now returned by the shared http helpers, microsoft-todo,
todoist and planka.
2026-09-05 11:55:07 +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
kolaenteandkolaente b042d8c77a Update auth.go 2026-09-04 11:02:36 +02:00
kolaente 3be5bf8c4f fix(metrics): don't log an error for every unauthenticated request
The metrics middleware called GetAuthFromClaims on every request and logged
its error when no JWT was in context. Unauthenticated routes (login, token
refresh, /info, websocket) hit that path constantly, flooding the log at
WARNING level and burying real errors.

Skip the active-user update when the request carries no credentials.
2026-09-04 11:02:36 +02:00
kolaente 077dc4de79 fix(migration): reload conflicting claims after rollback 2026-08-31 18:37:03 +02:00
kolaente e34314f605 test(migration): handle database id sequences 2026-08-31 18:37:03 +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