382 Commits
Author SHA1 Message Date
kolaente 4ae2e09301 fix(auth): reject API tokens at the OAuth authorize endpoint (GHSA-v3p6-34mc-hj7v) 2026-07-19 18:59:34 +02:00
kolaenteandkolaente f388015dda fix: interpolate dynamic values into translated error toasts
Parametrised error translations (invalid api token permission, invalid
timezone, invalid claim data) rendered their placeholders as empty text
because the backend only baked the values into the English message and
the frontend translated the error code without any interpolation params.

Carry the values as i18n_params on error responses, keyed by the
placeholder names of the frontend's error translations, on both v1 and
the v2 problem+json body, and pass them through when translating the
toast. A 14002 toast now reads e.g. "The permission time_entries of
group tasks is invalid." instead of "The permission of group is
invalid."
2026-07-18 13:41:15 +00:00
kolaenteandGitHub 9237c6d3f9 chore(deps): replace vendored humaecho5 with upstream humaecho adapter (#3205) 2026-07-17 22:35:53 +02:00
kolaenteandGitHub 85231284ab feat(auth): add OpenID provider availability monitoring and retry logic (#3145) 2026-07-10 17:52:01 +02:00
kolaenteandkolaente 167556ce2b feat(search): add explicit relevance sort field
sort_by=relevance renders as pdb.score(tasks.id) DESC at its requested
position, so clients can express orderings like done,relevance (undone
first, most relevant within each group). The implicit no-sort ranking
reuses the same mechanism via an injected sort param.

When the database or query shape cannot score, relevance params are
dropped and the remaining sorts plus the id tiebreaker apply, so the
field is safe to send unconditionally; getOrderByDBStatement also only
emits pdb.score when ParadeDB is actually available.

The quick-actions search sends sort_by=done,relevance (typed via
TaskFilterParams) and keeps surfacing undone tasks first.
2026-07-06 15:45:55 +00:00
kolaenteandkolaente 3a666da860 feat: audit admin reads and denied admin access
GET /admin/users returns every user's email address; compliance
regimes commonly require admin PII access to be logged, so the list
read now emits admin.users.listed. The admin gate additionally emits
admin.access.denied (outcome=failure, with method and path) whenever
an authenticated user without the instance-admin flag probes an
/admin/* route — dispatched directly with the request context since
there is no transaction.

Both entries have no single affected resource, so the entry target is
now omitted when empty instead of serialising a zero value.
2026-07-03 16:56:03 +00:00
kolaenteandkolaente 6f21c57c78 feat: audit admin user and project mutations
Every mutating admin action now dispatches a typed event on commit
(admin.user.created, admin.user.admin.granted/revoked,
admin.user.status.changed, admin.user.password.set,
admin.user.password_reset.sent, admin.user.deleted,
admin.project.owner.changed) which the audit catalog maps onto an
entry with the acting admin as actor and the affected user or project
as target. The model actions grow a doer parameter since the acting
admin was not threaded into the models layer before.

Admin creation deliberately emits both user.created (actor = the new
user, unchanged semantics) and admin.user.created (actor = the admin).
The events are instance-level, not project-scoped, so they are not
exposed as webhook events. No password or reset-token material is ever
part of a payload.
2026-07-03 16:56:03 +00:00
kolaenteandkolaente 39bbf8d006 fix: dispatch pending events in admin handlers
Neither the v1 nor the v2 admin handlers called events.DispatchPending
after commit, so events queued via DispatchOnCommit were silently
dropped: admin-created users never fired user.created (no webhook,
notification or audit entry) and mode=now user deletion dropped the
cascaded project.deleted events. Each dropped queue also leaked an
entry in the pendingEvents map keyed on the dead session.

Dispatch after commit, clean up on rollback, and thread the real
request context through the v2 handlers so request metadata flows onto
the events.
2026-07-03 16:56:03 +00:00
TinkandGitHub d0dccf2736 feat(pro): admin password reset for existing users (#3085) 2026-07-02 19:39:00 +02:00
kolaente fa0c9a8584 feat(api): exchange rich-text fields as markdown on v2
Wire the conversion helpers into every rich-text handler: read/list/echo
convert HTML to markdown, create/update convert markdown to HTML before
persisting, and each op documents the format query field. Opt-in via
?format=markdown or the X-Vikunja-Format header.
2026-06-29 08:12:35 +02:00
kolaente 71639a3dc5 feat(api): add v2 markdown conversion helpers
Adds the opt-in format plumbing for v2: requestWantsMarkdown (query or
X-Vikunja-Format header), convertToMarkdown/convertToHTML/convertTasksToMarkdown
field converters, the cross-cutting API description, and stripPatchFormatQuery
(AutoPatch drops the query, so PATCH advertises only the header).
2026-06-29 08:12:35 +02:00
kolaenteandkolaente 0d043e80e4 feat(api/v2): add kanban bucket CRUD endpoints
Port the standalone bucket list/create/update/delete from v1 to the
Huma-backed /api/v2, under /projects/{project}/views/{view}/buckets,
using v2 verb conventions (POST creates, PUT updates). The handlers
reuse the generic handler.Do* functions, so permissions are enforced
by the Bucket model's existing Can* methods.

Mirrors v1: no read-one route (the model has no ReadOne/CanRead), so
AutoPatch synthesises no PATCH. No model changes.
2026-06-26 08:56:15 +00:00
TinkandGitHub 7208694960 fix(auth): build OIDC end-session URL with RP-Initiated Logout params (#2943) 2026-06-19 18:27:33 +02:00
kolaenteandkolaente 6e1b15e344 fix(tasks): add labels sequentially when the backend db serializes writes
Quick Add Magic with multiple labels (`*a *b *c`) fired all
`PUT /tasks/{id}/labels` requests concurrently via `Promise.all`. On
SQLite these overlap as read-then-write upgrade transactions, which the
busy_timeout can't resolve, so some requests fail with HTTP 500
("database is locked") and the labels are silently dropped while the
quick-add input gets stuck.

Expose a `concurrent_writes` flag on the shared `/info` response (true
for Postgres/MySQL, false for SQLite). The frontend config store reads
it and `addLabelsToTask` now branches: parallel `Promise.all` when the
backend supports concurrent writes, sequential awaits otherwise.

Fixes #2680
2026-06-19 14:19:19 +00:00
TinkandGitHub f3c6312a9e feat(projects): make duplicating shares opt-in (#2932) 2026-06-19 10:15:58 +02:00
kolaenteandkolaente 40f2900e9d feat(api/v2): expose notifications atom feed in the OpenAPI spec
Adds GET /api/v2/notifications.atom as a Huma operation producing
application/atom+xml, so the feed shows in the v2 OpenAPI spec with an
opaque XML body schema. It mirrors /feeds/notifications.atom on the wire.

Feed readers can't carry a bearer header, so the op declares an HTTP
Basic security scheme (BasicAuth) and authenticates inside the handler:
it parses the Authorization: Basic header and validates the API token
via the shared feeds.AuthenticateFeedToken, returning a 401 with a Basic
challenge on failure, then streams feeds.BuildNotificationsAtomFeed. The
path is in unauthenticatedAPIPaths so the JWT middleware lets it through.
2026-06-17 20:35:28 +00:00
kolaenteandkolaente 1a4f03bbc8 feat(api/v2): expose healthcheck as a documented endpoint
Adds GET /api/v2/health as a Huma operation so it appears in the v2
OpenAPI spec with a clean JSON schema ({"status": "OK"}). It runs the
same health.Check() probe as the v1 healthcheck and is public — it opts
out of the global bearer auth and is listed in unauthenticatedAPIPaths.
2026-06-17 20:35:28 +00:00
kolaenteandkolaente 7c11c2dc29 feat(api/v2): port refresh-token endpoint to /api/v2
POST /api/v2/user/token/refresh reads the HttpOnly refresh cookie, rotates
the session, mints a new JWT, and sets the new cookie — reusing the shared
auth.RefreshSession core (no v1 change) and the #2912 cookie helpers /
authTokenBody response shape. The cookie is set via the unwrapped echo ctx,
not the OpenAPI spec.

translateDomainError now maps *echo.HTTPError (which RefreshSession returns
for missing/invalid/expired/replayed tokens) so those land as the right
status instead of a 500. Completes the v1→v2 REST migration.
2026-06-17 20:34:38 +00:00
kolaenteandkolaente 5b7924b1f6 fix(auth): return ErrAccountLocked for locked accounts on login
The login status check mapped a locked account to ErrAccountDisabled,
surfacing the disabled-account error code and message even though a
dedicated ErrAccountLocked exists (and the OIDC flow already uses it). Map
the locked status to ErrAccountLocked so credential login is consistent with
OIDC across both /api/v1 and /api/v2. Disabled accounts still return
ErrAccountDisabled.

This changes the v1 login error code for locked accounts on the wire (1020 ->
1026); the change is intentional and approved.
2026-06-17 19:43:41 +00:00
kolaenteandkolaente a32d8d6492 fix(auth): roll back on commit failure in DeleteSession
Restore the rollback-on-commit-failure that v1's Logout handler had before
this session-deletion logic was extracted, so a failed commit does not leave
the transaction open longer than the deferred Close.
2026-06-17 19:43:41 +00:00
kolaenteandkolaente 422d504a07 feat(api/v2): add OpenID Connect callback on /api/v2
Port the OIDC callback to Huma, reusing openid.AuthenticateCallback. The
route is only registered when OpenID is enabled; unknown providers still 404
per request. v1's bespoke {message, details} error body is replaced by
standard RFC 9457, folding the provider detail into the structured error.
2026-06-17 19:43:41 +00:00
kolaenteandkolaente d4ab438073 feat(api/v2): add login and logout on /api/v2
Port the cookie-setting login and logout endpoints to Huma. Both reuse the
shared auth cores; the HttpOnly refresh cookie and Cache-Control: no-store
header are set via the unwrapped echo context (the cookie stays out of the
OpenAPI schema, matching v1). The token response inlines the JWT to avoid a
schema-name collision with user.Token.

login is public (LDAP-only deployments log in here too); logout inherits the
global JWT auth and no-ops for tokens that carry no session.
2026-06-17 19:43:41 +00:00
kolaenteandkolaente 78f79accb5 refactor(auth): extract transport-agnostic login, logout and OIDC cores
Pull the credential/TOTP check, session deletion, user-token issuance and
OIDC callback flow out of the v1 echo handlers and into reusable helpers so
both /api/v1 and the upcoming /api/v2 share one implementation:

- auth.IssueUserToken + auth.WriteUserAuthCookies split the token/cookie
  machinery from the echo response; NewUserAuthTokenResponse now wraps them.
- auth.SessionIDFromContext reads the sid claim for logout.
- shared.AuthenticateUserCredentials, shared.DeleteSession hold the login
  and logout cores.
- openid.AuthenticateCallback holds the OIDC exchange/getOrCreate/TOTP/team
  sync, returning the user; HandleCallback issues the token as before.

v1 behaviour is unchanged on the wire.
2026-06-17 19:43:41 +00:00
kolaenteandkolaente 02e7a134cc fix(api): close the user data export reader after download
DownloadUserDataExport obtained an open file reader from
GetUserDataExportFile but never closed it on either the s3 io.Copy or the
http.ServeContent branch, leaking a file descriptor on every download.
Defer the close right after the file is obtained so both branches and the
error paths cover it.
2026-06-17 18:39:38 +00:00
kolaenteandkolaente 4b92f23329 fix(files): never cache file downloads in v1 or v2
Move the Cache-Control: no-cache header into the shared WriteFileDownload
so every export and attachment download carries it, and add it to the
standalone v1 export download writer too. Downloads must never be cached.
2026-06-17 18:39:38 +00:00
kolaenteandkolaente ee8dbf82ba fix(api/v2): close export reader when commit fails before streaming
If s.Commit() fails after loading the export file, the StreamResponse
callback that would close the reader never runs, leaking the open
object-storage/file handle. Close it explicitly on that error path.
2026-06-17 18:39:38 +00:00
kolaenteandkolaente 8c72e83a4d feat(api/v2): add user data export endpoints
Port POST /user/export/request, POST /user/export/download (zip stream) and
GET /user/export (status) to v2. Extract the export-file loader and status
builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with
a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2
download streams via the shared WriteFileDownload writer; local users confirm
with their password, external-provider users are passed through.
2026-06-17 18:39:38 +00:00
kolaenteandkolaente ac5e94252b feat(api/v2): add totp qr code endpoint
Port GET /user/settings/totp/qrcode to v2 as an image/jpeg blob, modeled in
the OpenAPI spec. Extract the qr-to-jpeg encoding into user.GetTOTPQrCodeAsJpegForUser
so v1 and v2 share it; refactor v1 onto it. The handler reuses the existing
local-account guard, rejecting non-local users with 412.
2026-06-17 18:39:38 +00:00
kolaenteandkolaente 4737114b12 feat(api/v2): add e2e testing-support endpoints on /api/v2
Port the testing fixture endpoints to /api/v2: PUT /test/{table} resets a
table to a posted fixture set and DELETE /test/all truncates everything.
Both authenticate with the configured testing token via a custom
Authorization header (not JWT/API-token) and only mount when that token is
set. Reuses the shared reset/truncate logic extracted from v1.
2026-06-17 12:13:50 +00:00
kolaenteandkolaente 5555950f03 refactor(testing): extract e2e fixture reset/truncate into shared package
Pull the HTTP-agnostic table reset and truncate-all logic out of the v1
testing handlers into pkg/routes/api/shared so /api/v2 can reuse it. v1's
wire behavior is unchanged; it now delegates to the shared functions.
2026-06-17 12:13:50 +00:00
kolaenteandkolaente 5ccbd0d74e feat(api/v2): add project background download and unsplash proxies
Port the remaining read-only background blob endpoints to /api/v2:

- GET /projects/{project}/background streams the stored background (project
  CanRead, in-handler), modeled as an image/jpeg binary response. Honors
  If-Modified-Since (304) and serves through the shared WriteProjectBackground.
- GET /backgrounds/unsplash/images/{image} and .../thumb proxy the upstream
  Unsplash image through the SSRF-safe client, gated on the unsplash provider
  like the sibling unsplash routes, modeled as image/jpeg binary responses.

All three reuse the v1 business logic extracted in the previous commit.
2026-06-17 11:31:50 +00:00
kolaenteandkolaente 77416d32e4 feat(api/v2): add the generic CSV importer on /api/v2
Port the CSV importer's status/detect/preview/migrate endpoints to the Huma
API. detect/preview/migrate take a multipart upload; preview and migrate also
carry the import config as a JSON form value (modeled as a typed multipart
form field), unmarshaled in one shared place and reused via csv.RunMigration.
2026-06-12 08:51:19 +00:00
kolaenteandkolaente a21822fcec feat(api/v2): add file migrators (vikunja-file, ticktick, wekan) on /api/v2
Port the file-based migrators' status + migrate endpoints to the Huma API.
A single registerFileMigrator helper wires all three (mirroring the OAuth
migrator registrar); the migrate endpoint takes a multipart upload under the
"import" field and reuses handler.RunFileMigration. POST migrate returns 200
since it runs an import rather than creating a REST resource.
2026-06-12 08:51:19 +00:00
kolaenteandkolaente 3af5eb8208 feat(api/v2): add project background upload on /api/v2
Port PUT /projects/{project}/backgrounds/upload to the Huma-backed v2 API. The
multipart handler reuses handler.ValidateAndSaveBackgroundUpload (shared with
v1), checks project write access explicitly, and is gated on the upload provider
config flag. Adds webtests covering the happy path, auth/permission failures,
non-image rejection, the disabled-provider case and the multipart spec shape.
2026-06-12 08:47:08 +00:00
kolaenteandkolaente 5e00fcbbb8 chore(lint): suppress contextcheck on OIDC provider init call sites
Adding a context parameter to the shared package put its call chains in
contextcheck's scope; the flagged background context in the provider
setup is deliberate since provider lifetime exceeds any request.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente acdc2a07f2 feat(audit): emit the login event for the OAuth code exchange
The new v2 OAuth token endpoint mints a fresh session without going
through NewUserAuthTokenResponse, so those logins were missing from the
audit trail. The refresh grant stays unaudited like the v1 refresh.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente f33cde82e2 feat(audit): attribute failed logins to the originating request
Thread the request context through CheckUserCredentials so the
LoginFailedEvent carries IP, user agent and request id — without it,
failed logins were the one auth event useless for brute-force tracing.
All four callers have the request at hand.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente 3291556821 fix(audit): only attribute the logout event to user tokens
Link share JWTs carry no sid claim so they returned before the event
fired, but the id claim was read without checking the token type. Make
the guard explicit so a link share id can never appear as a user id.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente b86710903b fix: dispatch pending events after user creation commits
The register handler, local/LDAP login and the OIDC callback all queue
the user.created event via DispatchOnCommit but never called
DispatchPending, so the event was silently dropped and its queue entry
leaked. Flush after commit and discard on rollback.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente 9da51f5096 refactor(events): pass context to DispatchPending directly
Every DispatchPending caller either has the request context in scope or
is genuinely request-less, so passing it as a parameter replaces the
stored-context mechanism on the pending queue and satisfies
contextcheck. Also fixes lint findings in the audit package.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente 5f4a21a4c5 feat(events): add auth boundary events
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.

None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
2026-06-12 08:56:08 +00:00
kolaenteandkolaente 2bbe77c141 fix(api/v2): gate /register at registration time, not per request
Per review: when registration is disabled, skip registering the
/register route entirely instead of registering it and returning 404 on
every request. A request to a disabled instance still 404s (unknown
route). ServiceEnableRegistration is static config, so the gate belongs
in the registrar.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente d8ad9d64f5 test(api/v2): cover ported auth/token endpoints
Add webtests mirroring the v1 coverage for the v2 auth surface:
register (incl. registration-disabled 404), password reset request +
reset, email confirm, link-share auth (password matrix), the OAuth token
flow in both JSON and form-urlencoded encodings, oauth/authorize, the
token-test/check endpoints (200, not 418), /routes and link-share token
renewal (incl. user-token rejection).

Also make the link-share auth body optional so a passwordless share
authenticates with no request body, matching v1.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente 56a516045b feat(api/v2): add token-check, token-routes and link-share renew endpoints
Port the token introspection helpers and link-share token renewal to
/api/v2:

- GET/POST /token/test both return a plain 200 "ok"; v1's POST 418
  teapot easter egg becomes an ordinary success.
- GET /routes lists the scoped-token routes for both API versions
  (models.GetAPITokenRoutes already merges v1 + v2).
- POST /user/token renews a link-share JWT; user tokens are rejected
  (they must use the refresh-token flow), mirroring v1.

The renew response inlines the token field rather than returning
auth.Token directly, since Huma names schemas by bare type and a
top-level auth.Token body would collide with user.Token.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente dc4c3a6a17 feat(api/v2): add OAuth 2.0 token and authorize endpoints
Port oauth/token and oauth/authorize to /api/v2, delegating to the
shared oauth2server.ExchangeToken / Authorize cores.

The token endpoint accepts spec-compliant application/x-www-form-urlencoded
bodies (RFC 6749) in addition to JSON; a form-urlencoded format is
registered on the v2 API that binds into the same json-tagged request
struct. The response carries Cache-Control: no-store. The token endpoint
is public; authorize inherits the global JWT auth.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente 37a174b99e feat(api/v2): add public auth routes (register, password, confirm, link-share)
Port the unauthenticated local-account flows and link-share auth to
/api/v2, delegating to the shared business logic:

- POST /register (404 when registration is disabled)
- POST /user/password/token, POST /user/password/reset
- POST /user/confirm
- POST /shares/{share}/auth

Local-account routes register only when local auth is enabled and the
link-share route only when link sharing is enabled, mirroring v1. Each
operation opts out of global auth and its path is added to
unauthenticatedAPIPaths.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente eac1fa2726 refactor(auth): extract shared auth/token business logic for v2 reuse
Pull the HTTP-independent core out of the v1 auth handlers so both
/api/v1 and the upcoming /api/v2 routes share one implementation:

- oauth2server: ExchangeToken and Authorize take plain inputs and return
  typed responses; HandleToken/HandleAuthorize keep binding + headers.
- pkg/routes/api/shared: AuthenticateLinkShare, RegisterUser,
  ResetPassword (+ session clear), RequestPasswordResetToken and
  ConfirmEmail, plus the shared UserRegister and LinkShareToken types.

v1 handlers now delegate to these; their wire output is unchanged.
2026-06-12 07:58:17 +00:00
kolaenteandkolaente 5807f2e7b4 refactor(user): share user-search logic between v1 and v2
Extract the duplicated user-search business logic into two helpers both API
versions call, and refactor v1's handlers onto them:
- user.SearchUsers wraps ListUsers + email obfuscation (global search)
- models.SearchUsersForProject wraps the project read check + ListUsersFromProject

Each handler keeps its own forbidden mapping (v1 echo.ErrForbidden vs v2
huma) so v1 stays byte-identical on the wire.
2026-06-11 20:07:43 +00:00
kolaenteandkolaente 5dcc501d54 feat(api/v2): add user search endpoints
Port to /api/v2:
- GET /users (global user search by username/name/email; emails are blanked)
- GET /projects/{project}/users/search (users with access to a project, for
  share autocomplete; requires project read access)

Both are custom routes: the project search loads the project and enforces
CanRead explicitly.
2026-06-11 20:07:43 +00:00
kolaenteandkolaente 3312716afd feat(api/v2): add available webhook events endpoint
Add GET /api/v2/webhooks/events, listing the events a webhook target can
subscribe to. Gated on webhooks.enabled via a registrar early-return, mirroring
v1.
2026-06-11 20:07:43 +00:00