Commit Graph

372 Commits

Author SHA1 Message Date
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
kolaente 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
Tink 7208694960
fix(auth): build OIDC end-session URL with RP-Initiated Logout params (#2943) 2026-06-19 18:27:33 +02:00
kolaente 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
Tink f3c6312a9e
feat(projects): make duplicating shares opt-in (#2932) 2026-06-19 10:15:58 +02:00
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 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
kolaente 56b1ba47ec feat(api/v2): add public instance info endpoint
Add GET /api/v2/info (public — no auth). Extract the /info response type and
its assembly out of the v1 handler into pkg/routes/api/shared.BuildInfo() so
both API versions return byte-identical info; refactor v1's handler onto it.
Add the v2 path to unauthenticatedAPIPaths.
2026-06-11 20:07:43 +00:00
kolaente 6f3dab53cb feat(api/v2): add project background endpoints
Port to /api/v2:
- DELETE /projects/{project}/background (remove background, returns the updated project)
- GET /backgrounds/unsplash/search (q, page; gated on the unsplash provider)
- PUT /projects/{project}/backgrounds/unsplash (set, gated on the unsplash provider)

Custom routes load the project and enforce CanUpdate explicitly. Backgrounds
are gated on the static backgrounds config via a registrar early-return.
Tag background.Image fields with doc: for the v2 schema, and add a scoped
contextcheck exclusion since the unsplash provider's shared interface bottoms
out in context.Background().
2026-06-11 20:07:43 +00:00
kolaente 53d1fa0735 refactor(admin): share user-mutation logic between v1 and v2
The admin set-admin-flag, set-status and delete-user operations were
implemented twice — once in the v1 echo handlers, once in the v2 Huma handlers.
Extract the load/guard/mutate logic into models.SetUserAdminFlag,
models.SetUserStatusAsAdmin and models.DeleteUserAsAdmin so both APIs call the
same code; each handler keeps only its own request binding, validation and
response shape. v1 stays byte-identical on the wire.
2026-06-11 19:32:42 +00:00
kolaente 5b3ee89edd refactor(api/v2): dedup the admin user-mutation handlers
The patch-admin, patch-status and delete-user handlers each repeated the same
session open/load/commit/rollback scaffold. Extract it into adminMutateUser,
which owns the transaction and takes a closure for each handler's distinct
guard-and-write step.
2026-06-11 19:32:42 +00:00
kolaente 5579daa452 feat(api/v2): add admin actions on /api/v2
Port the admin action endpoints to the Huma-backed /api/v2:

- GET    /admin/overview            instance counts + license snapshot
- POST   /admin/users               create a user (201)
- PATCH  /admin/users/{id}/admin    promote/demote (*bool, nil = unchanged)
- PATCH  /admin/users/{id}/status   set status (*Status, nil = unchanged)
- DELETE /admin/users/{id}          delete (mode=now|scheduled, 204)
- PATCH  /admin/projects/{id}/owner reassign project owner

All sit behind the existing gateV2AdminRoutes path middleware (admin + license
gate, 404 on failure), so no per-handler permission checks are added. The
hand-registered PATCH routes carry genuine partial semantics, which AutoPatch
does not synthesise. The admin user response reuses the existing
pkg/routes/api/shared package.
2026-06-11 19:32:42 +00:00
kolaente e25f997281 refactor(admin): extract shared admin overview, user-create and user-view helpers
Move the admin overview computation and struct into models.BuildOverview /
models.Overview, the admin create-user flow into models.CreateUserAsAdmin /
models.CreateUserBody, and the admin user response view into a new
pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both
the v1 and v2 admin routes call the same code. The v1 handlers are refactored
onto these helpers and stay byte-identical on the wire.
2026-06-11 19:32:42 +00:00
kolaente 9c3c1047ac feat(api/v2): port OAuth migrators (Todoist, Trello, Microsoft To-Do)
Add /api/v2 auth/status/migrate endpoints for the three OAuth-based
migrators. One generic helper registers all three ops per migrator
behind its static config gate, so there's no copy-pasted block per
migrator.

The migrate kick-off orchestration (already-running guard + event
dispatch) is extracted into migrationHandler.StartMigration so v1 and
v2 share it; v1's wire output is unchanged. The guard now surfaces as a
typed migration.ErrMigrationAlreadyRunning (412) so v2 can translate it
through the standard error bridge.
2026-06-11 18:35:55 +00:00
kolaente 809ac118f9 refactor(api/v2): dedup task collection query params via exported embed 2026-06-11 18:31:03 +00:00
kolaente 3bd75acabf feat(api/v2): add task collection (task lists) on /api/v2
Ports v1's task-list surface to /api/v2 as four endpoints. v1 served a
single polymorphic endpoint; v2 makes it monomorphic:

  GET /tasks                                     flat []*Task, all projects
  GET /projects/{project}/tasks                  flat []*Task
  GET /projects/{project}/views/{view}/tasks     flat []*Task (even kanban)
  GET /projects/{project}/views/{view}/buckets/tasks   []*Bucket with tasks

The three task endpoints force flat tasks via TaskCollection so a kanban
view path no longer returns buckets; the dedicated buckets endpoint keeps
the polymorphic kanban branch and is not paginated (bounded by the view's
bucket config). Search is exposed as q; multi-value sort_by/order_by/expand
use ,explode. Hitting the buckets endpoint with a non-kanban view is a 400
rather than a type-mismatch 500.
2026-06-11 18:31:03 +00:00
kolaente 28af57bc93 feat(api/v2): add user account/settings on /api/v2
Port the current-user account and settings endpoints from /api/v1 to the
Huma-backed /api/v2, calling the shared orchestration extracted into
models/user/openid:

- GET    /user                            current user + settings + computed
                                          auth_provider/is_local_user/is_admin
- POST   /user/password                   change password (200, creates nothing)
- PUT    /user/settings/email             update email (kicks off confirmation)
- PUT    /user/settings/general           update general settings
- GET    /user/settings/avatar/provider   get avatar provider
- PUT    /user/settings/avatar/provider   set avatar provider
- GET    /user/timezones                  list available time zones

These are current-user-scoped custom handlers (no per-resource Can*): each
pulls the authed user from the request context and operates on it. The avatar
provider get/set live on /user/settings/avatar/provider because v2 already
maps /user/settings/avatar to the binary avatar upload (PUT).
2026-06-11 07:02:31 +00:00