Add ProjectView CRUD on /api/v2 under the nested path
/projects/{project}/views[/{view}], establishing the two-path-param
binding pattern for sub-resources. Mirrors the labels.go handler shape
and reuses handler.Do* so permission checks stay at the model layer.
Both {project} and {view} are bound on every operation; {project} is
threaded onto ProjectView.ProjectID (ReadOne resolves via
GetProjectViewByIDAndProject, which needs the parent id). List wraps the
[]*models.ProjectView slice in the shared Paginated envelope, read sends
an ETag for If-None-Match/304, and AutoPatch synthesises PATCH.
Also:
- Tag exposed ProjectView / ProjectViewBucketConfiguration / nested
TaskCollection fields with doc: descriptions; mark server-controlled
fields (id, project_id, created, updated) readOnly. Safe for v1.
- Give ProjectViewKind and BucketConfigurationModeKind a huma.SchemaProvider
so the string-serialised enums reflect as string schemas instead of
Huma's default integer schema (which rejected the string form with 422).
Routes registered in registerAPIRoutesV2 before EnableAutoPatch.
Collapsing unparseable taskIds to 0 meant sortParentsBeforeChildren,
which tracked placement by TaskID, treated every zero-id task after the
first as already placed and silently dropped it. Track placement by task
identity instead so duplicate or zero ids never conflate distinct tasks.
TickTick exports could contain non-numeric values in columns Vikunja
parses as integers (Priority, taskId, parentId). gocsv's strconv.ParseInt
then failed, aborting the entire import and surfacing as an internal
server error reported to Sentry (e.g. parsing "p1": invalid syntax).
Numeric ID columns now fall back to 0 for unparseable values instead of
failing the import. The Priority column, which was previously parsed but
never carried over to the imported task, is now mapped onto the task and
accepts both the plain numeric form (0, 1, 3, 5) and the "pN" form
(p1, p2, p3).
Closes#2822
The `service.allowiconchanges` config option was ignored. On the web ui the
value injected into index.html by the api was immediately overwritten by a
hardcoded `window.ALLOW_ICON_CHANGES = true` in a later inline script, so the
configured value never took effect. The desktop app never received the
injected value at all, since it serves the bundled frontend from its own local
server and only talks to the api for data.
Expose the option via the /info endpoint and read it from the config store,
which is the only channel that reaches both the web ui and the desktop app.
The brittle window injection and its hardcoded default are removed in favor of
this single source of truth.
https://claude.ai/code/session_01HAXTJNsDcfsB4hwDNKTECb
v2's OpenAPI spec is generated from struct tags and Operation fields at
runtime; unlike swaggo (v1) it can't read Go doc comments, so v2 shipped
without the field/operation descriptions v1 has. Add doc: tags to the
Label model (kept in sync with the existing comments swaggo reads for
v1) and Summary/Description to each label operation. Makes labels a
complete reference for the pattern.
The normalizer's docstring and stripBracketSuffix's pair-by-pair walk
promise left-to-right order preservation (load-bearing for sort_by /
order_by), but the only coverage used order-insensitive assert.Contains
after 02e10b287 dropped the dedicated test. Add exact-match assertions
that a mix of plain and bracketed forms re-emits values in send order.
Huma's handler-error path wraps raw errors as NewErrorWithContext(ctx,
500, "unexpected error occurred", err), and since the humaecho5 adapter
writes Huma's response directly it bypasses Vikunja's
CreateHTTPErrorHandler — which returns a generic 500 with no detail for
non-domain errors. The huma.NewError override then copied err.Error()
(raw DB/driver messages, SQL, table/column names) into the problem+json
errors[], a regression vs v1.
Override huma.NewErrorWithContext to drop errs for status >= 500, log
the real cause server-side, and return a generic body. 4xx detail
(validation errors, domain messages) is unaffected.
PermissionsAreValid only consulted apiTokenRoutes, so a v2-only resource
(no v1 counterpart) could never be granted as a token scope even though
CanDoAPIRoute already authorises against both tables. Validate against
the union so the v1+v2 authorization and validation paths agree.
translateDomainError discarded web.HTTPError.Code, so v2 error bodies
always read code 0 — losing the v1 contract the error docs key off.
Override huma.NewError with a VikunjaErrorModel that adds a code field,
so both the generated OpenAPI schema and runtime responses carry it.
Domain errors with a numeric code now surface it (e.g. 8002 for a
missing label, matching v1); errors without one omit it.
authFromCtx surfaced the underlying GetAuthFromContext error message
(e.g. the internal 'no echo.Context' adapter detail) straight to the
client. Log the real error and return a generic 401 instead.
The authenticated v1 group installs setupRateLimit and
setupMetricsMiddleware; the v2 group only had cache-control and token
middleware, so authenticated v2 endpoints bypassed the configured API
rate limiter and route metrics. Mirror the v1 stack.
Huma's SchemaLinkTransformer (enabled by default) emits a `$schema`
field on every JSON response and an example URL in the spec. Both were
broken in our setup: the example URL used Huma's "https://example.com"
placeholder because no Servers were declared, and the runtime URL
pointed at /schemas/Label.json instead of /api/v2/schemas/Label.json
because Huma can't see the Echo group prefix.
Two changes:
- Set OpenAPI Servers to a list with the relative GroupPrefix first and,
if service.publicurl is configured, the absolute deployment URL
second. Servers[0] feeds Huma's getAPIPrefix / addSchemaField /
Transform fallback; Servers[1] is informational metadata for SDK
generators and docs UIs. Keeping the relative URL at index 0 dodges a
Huma quirk that double-prefixes the runtime $schema URL when the
index-0 server URL carries a path component.
- Add /api/v2/schemas/:schema to unauthenticatedAPIPaths so editors and
SDK tooling can fetch schemas without a token, mirroring how the spec
itself is reachable.
Seven integration tests covering the Label pilot:
- Create_Read_Update_Delete — full round-trip through POST/GET/PUT/
DELETE, asserts body + status at each step.
- List_ReturnsItems — GET /labels, asserts items[] is non-empty and
contains a known fixture; this is the regression catcher for the
generic-any silent-empty trap the spike hit.
- ForbiddenErrorShape — user1 reading user13's private label returns
403 problem+json with the RFC 9457 type/title/status/detail shape.
- ValidationErrorShape — POST with empty title fails Huma's
minLength:1 check with 422 problem+json + structured per-field
errors locating `title`.
- ETagReturns304 — first GET captures ETag, second GET with
If-None-Match returns 304.
- PATCHMergePatch — AutoPatch-synthesised PATCH with partial
application/merge-patch+json body updates one field and leaves
the others untouched; a follow-up GET confirms preservation.
- OpenAPISpecDescribesAllFive — the unauthenticated
/api/v2/openapi.json surfaces GET+POST on /labels and GET+PUT+
DELETE on /labels/{id}.
Wires five hand-written huma.Register calls for Label CRUD onto the
existing /api/v2 group: list, read, create, update, delete. Uses
concrete type cast on ReadAll to avoid the generic-any silent-empty
trap. The read operation exposes an ETag via a header-tagged output
struct field and honours conditional.Params so clients can get 304
Not Modified on subsequent reads.
Also closes a prior-phase gap: SetupTokenMiddleware was intended to
run on the /api/v2 group (per task B4 of the plan) but was never
wired. Attach it now and teach the skipper to consult
unauthenticatedAPIPaths so spec + docs remain public.
The /api/v1 group sets Cache-Control: no-store to prevent browsers
from heuristically caching JSON responses. /api/v2 was missing the
same header, which could lead to stale reads. Extracted the inline
middleware into a shared noStoreCacheControl helper and applied it
to both groups.
Huma's AutoPatch synthesises a PATCH counterpart for every PUT, and both
verbs collapse to the same "update" permission. PATCH is still skipped
during collection (it would clobber PUT under the shared key), but the
matcher now accepts it as an alias for the stored PUT route on the same
path, so token holders aren't forced to use PUT exclusively.
Sub-phase G validation caught that a token scoped to e.g.
`labels.read_one` was rejected on /api/v2/labels because the route
collector only stripped /api/v1/ from paths and did not know about
v2's REST-style verbs (POST create, PUT/PATCH update, inverted
from v1 where PUT creates and POST updates).
Introduce a shadow apiTokenRoutesV2 map keyed under the same
(group, permission) names as the v1 entries. Route collection now
routes v2 paths into this shadow map and CanDoAPIRoute consults
both tables, so the same permission bit authorizes the v1 and v2
endpoints for the same resource without changing the data shape
served at /api/v1/routes (which the frontend token UI depends on).
Also teach getRouteDetail about PATCH so Huma's AutoPatch-synthesized
PATCH routes collapse to the `update` permission instead of being
dropped.
A GetWithValue deserialization error in RememberFor was returned as fatal.
On a Redis upgrade the metrics counters live under the same keys as before
but were stored as plain int64, so the first decode into the new envelope
would fail and the metric would break permanently. Treat such errors as a
miss and recompute/overwrite so the cache self-heals.
Instead of priming a counter at startup and keeping it in sync via events,
each entity count is now read directly from the database and cached for
30s (countCacheTTL). The cache is the correctness guarantee: counts are at
most one TTL stale and self-healing, so they can never permanently drift.
This fixes vikunja_user_count never updating after registration (#2650):
the count no longer depends on every mutation path dispatching an event.
A comment whose body contains <blockquote data-comment-id="…"> nodes
now triggers the same task-comment mention notification for the
quoted comments' authors, respecting CanRead, subscription, and
existing dedup. Self-quotes, wrong-task quotes, and malformed ids
are silently skipped.
el-GR translations are around 36% complete but were not yet listed in the
UI. Add it to the supported locales list (frontend and backend) and wire
up the dayjs locale mapping.
Seed the dedup map at the start of insertFromStructure with the importing
user's existing labels, keyed by title + normalized hex color. Previously
the map was empty on each run, so importing the same CSV (or any other
migration format) twice would create a second copy of every label.
Scoped to the user's own labels so imports don't silently link to other
users' labels visible via shared projects.
Fixes#2742
Switches the input normalisation from lower- to uppercase so identifiers
canonicalise the same way GitHub-style refs do (e.g. "PROJ-42"). The
positive identifier tests are dropped for now because the existing
fixtures store identifiers as lowercase ("test1") and the SQL comparison
remains case-sensitive — once the column-side case-insensitive match
lands, full coverage can be reinstated.
Normalises the input side so GitHub-style references like "TEST1-42" and
"test1-42" resolve to the same project. The SQL comparison itself remains
case-sensitive for now; case-insensitive matching on the column will be
addressed separately.
Allows GET /projects/{project}/tasks/by-index/{index} to resolve {project}
as either a numeric id or a project identifier (e.g. "PROJ"), so callers
can build GitHub-style task references like "PROJ-42" without first
looking up the project's numeric id. Pure-digit values remain interpreted
as ids, which makes identifiers consisting solely of digits unreachable
via this route.
The conversational mail template does not reference cid:logo.png, but
RenderMail still attached the embedded logo to every outgoing mail.
That left an orphan inline part that some clients render as a stray
attachment. Only embed logo.png when the formal template is in use.
Hardcoding the three exact strings localhost / 127.0.0.1 / ::1 rejected
legitimate loopback redirects like 127.0.0.2:1234 (anywhere in 127.0.0.0/8)
or [0:0:0:0:0:0:0:1]:1234 (expanded IPv6 loopback). Use net.IP.IsLoopback()
to cover the full loopback ranges, and match "localhost" case-insensitively.
0.0.0.0 stays rejected as it is not a loopback address.
https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB
Previously the OAuth server rejected every redirect_uri that did not start
with a vikunja- custom scheme. Native apps that cannot register a custom
scheme (e.g. CLIs, desktop tools) need loopback redirects per RFC 8252, so
also allow http://localhost, http://127.0.0.1 and http://[::1] (any port).
Non-loopback http:// and https:// targets remain rejected.
https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB
TaskAttachment.ReadOne now swallows ErrAccountDisabled/ErrAccountLocked
from the creator lookup, matching the existing ErrUserDoesNotExist
swallow. Without this, deleting a disabled user that owned a project
with task attachments would fail when the cascade re-loaded the
attachment to delete it.
Removes the `service.enablebotusers` config flag, the matching
`bot_users_enabled` field on /info, and the now-unused
`ErrBotUsersDisabled` error. Bot user routes and the frontend
settings tab are now always available.
https://claude.ai/code/session_01VhAR6xnoCdG1fpX52bzaCC
User search previously filtered bots only when they happened to match the
search string. That produced two bad behaviours:
1. Bots owned by other users could surface on an exact-username match,
leaking them into assignee pickers and similar UI.
2. A user could not reliably find their own bots by typing a partial
name, so bots became awkward to assign to tasks.
Change ListUsers to treat bot ownership explicitly: the existing match
branch excludes rows owned by someone else, and a second branch always
returns bots owned by the calling user. The own-bots branch also
respects any AdditionalCond passed in so project-scoped listings don't
start leaking bots from outside the project.
Bot users now render with a cool-toned (blue/cyan/violet/teal/indigo)
marble variant so they're visually distinguishable from human users.
Marble's rendering logic is parameterized with a palette; the route
forces the bot palette whenever the resolved user is a bot, overriding
whatever avatar provider they'd otherwise inherit.
The JWT skipper bypassed validation entirely for /token/test when the
bearer was an API token, leaving "user" unset in the context. CheckToken
then type-asserted it to *jwt.Token and panicked.
Validate the API token in the skipper but skip the route permission
check (since /token/test is not exposed in the API token route registry,
no token can hold explicit permission for it). Drop the now-redundant
JWT assertion in CheckToken — auth has already passed by the time the
handler runs.
Remove five keys from pkg/i18n/lang/en.json that are no longer
referenced by any i18n.T / i18n.TP call. These surfaced once the
translation check started reporting dead keys. The sibling translation
files will be reconciled on the next Crowdin sync.
Removed keys:
- notifications.task.comment.mentioned_message
- notifications.task.mentioned.message
- notifications.common.actions.assigned_you
- notifications.common.actions.assigned_themselves
- notifications.common.actions.assigned_user
The call to i18n.T for notifications.task.overdue.overdue was missing
its first positional argument, so the translation key was being passed
as the language code. This surfaced as a "dead key" once the
translation check learned to look for unused entries. Fix the call so
the reminder line is properly localised.
Keeps the Do* helpers framework-neutral so non-Echo callers (upcoming
Huma /v2 handlers) don't need a translation shim.
Addresses review feedback on #2670.
GuardLastAdmin counted only active, non-deletion-scheduled admins, but gated only on target.IsAdmin. Demoting or deleting an already-disabled or deletion-scheduled admin would then be blocked whenever exactly one active admin remained, even though removing a user who isn't in the reachable set can't reduce the count. Return early when the target isn't part of the counted set.
On startup, if the license server was unreachable with no usable cached status, or the server rejected the key, we only logged a warning without clearing persisted license.state. On Redis/keyvalue deployments a previous run's Licensed=true could remain active even though pro features were advertised as unavailable. Route both paths through degradeToFree so the persisted state is cleared.
The last-admin guard was only enforced in the --now branch of 'user delete'. The default scheduled path called user.RequestDeletion without the guard, letting an operator schedule deletion of the last reachable admin via the CLI; the cron flow would then confirm and execute it, violating the invariant the HTTP admin API already enforces.
The admin create-user handler returned the in-memory newUser struct directly. On mail-enabled instances with skip_email_confirm=false, user.CreateUser persists the account as email-confirmation-required, but the returned struct still reflects the pre-persist status, so the admin API reported a misleading active status immediately after creation.
Call license.Init() after database initialization and before the web
server starts. Call license.Shutdown() during graceful shutdown to stop
the background check goroutine.
Implement the license validation system with:
- Server communication with retry logic and exponential backoff
- In-memory state management for feature flags and user limits
- Cached validation with 72h expiry stored in database
- Background goroutine with adaptive check intervals (24h/1h)
- Graceful degradation to community mode on failure
- Instance ID generation and persistence
Parse the top-level `attachments` array in WeKan board JSON exports,
group them by card ID, base64-decode the payload, and attach the
resulting files to the generated tasks so they land in Vikunja as
task attachments. Orphaned attachments (cardId with no matching card)
are silently skipped; decode errors are logged and skipped.
The four boolean OIDC provider fields (emailfallback, usernamefallback,
forceuserinfo, requireavailability) were parsed with a strict .(bool)
type assertion. That works for YAML/JSON config where leaves are native
bools, but fails for every other input path: env vars always arrive as
strings, and GetConfigValueFromFile (used by the *.file Docker secret
convention) also always returns strings. The assertion would silently
zero the field for emailfallback and usernamefallback, and log an error
and zero the field for forceuserinfo and requireavailability, which is
what #2599 reports.
Extract a small parseBoolField helper that accepts both native bools and
strings (via strconv.ParseBool) and logs a parse error from each call
site. This also fixes the previously-silent drop of stringified
emailfallback / usernamefallback values — those now log an error if the
input is garbage, matching the behaviour of the other two fields.
Fixes#2599
Regression test for #2599. Exercises getProviderFromMap with native
bools and with stringified booleans ("true"/"false"/"1"/"0") for all
four boolean provider fields — emailfallback, usernamefallback,
forceuserinfo, requireavailability. From env vars and from the
GetConfigValueFromFile path every leaf arrives as a string, so the
current .(bool) assertion silently zeros these fields.
MySQL 8 rejects CAST(... AS int) (only SIGNED/UNSIGNED/CHAR/... are
accepted as target types), causing /api/v1/projects, /api/v1/tasks,
and /api/v1/labels to return HTTP 500 for every authenticated user on
MySQL 8. SQLite, Postgres, and MariaDB lax mode silently accepted the
expression, which is why the regression (introduced in e3045dfd0,
shipped in v2.3.0) passed CI — the mysql CI matrix leg uses
mariadb:12, not real MySQL 8.
Replace the two CAST(all_projects.is_archived AS int) expressions in
the recursive project CTE with MAX(CASE WHEN ... THEN 1 ELSE 0 END),
which is dialect-agnostic and needs no cast on any supported backend.
Fixes#2589
Regression test for #2589. Locks the contract that getAllProjectsForUser
exposes inherited is_archived for child projects of archived parents and
filters them out when getArchived=false, exercising both the MAX(...)
column expression and the HAVING MAX(...) = 0 filter.
The OIDC callback handler previously issued a JWT without ever
checking TOTP state. For installations with EmailFallback (or
UsernameFallback) enabled, this allowed an attacker who could
authenticate at the IdP with a matching email to log in as a local
user with TOTP enrolled, bypassing the second factor entirely.
HandleCallback now runs enforceTOTPIfRequired after resolving the
user and before any team sync writes, returning 412/1017 when the
passcode is missing or invalid. Clients resubmit the OIDC flow with
the totp_passcode field populated.
Fixes GHSA-8jvc-mcx6-r4cg
Extracts a TOTP gate that the OIDC callback will use to enforce 2FA
for users with TOTP enabled. Mirrors the local-login TOTP flow in
pkg/routes/api/v1/login.go. Not yet wired into HandleCallback.
Refs GHSA-8jvc-mcx6-r4cg
Covers the four states the OIDC TOTP gate must handle: user without
TOTP, TOTP enabled with missing passcode, invalid passcode, and
valid passcode. The helper function under test does not exist yet,
so the package currently fails to compile.
Refs GHSA-8jvc-mcx6-r4cg
Prepares the OIDC callback struct to carry a TOTP passcode so the
handler can enforce 2FA for users with TOTP enabled. No behaviour
change yet.
Refs GHSA-8jvc-mcx6-r4cg