2743 Commits
Author SHA1 Message Date
kolaente d545e3bbb1 refactor: drop redundant link share branch in comment permissions
GetID() delegates to getUserID(), so both branches compute the same value.
2026-08-04 13:40:11 +02:00
kolaente ed8dc89f75 fix: report the real link share id in saved filter errors
auth.GetID() is now negated, so ErrSavedFilterNotAvailableForLinkShare
carried a value that is not a link share id.
2026-08-04 13:40:11 +02:00
kolaente ccba2e4546 fix: avoid panic on null jwt claim values
reflect.TypeOf returns a nil Type for a null claim value, so building the
error message panicked.
2026-08-04 13:40:11 +02:00
kolaente dbe664dec5 fix(security): reject non-user tokens in GetUserFromClaims
GetUserFromClaims read the id claim straight into User.ID without checking the
token type. A link share JWT carries the raw, positive share id in that claim
and reaches every authenticated route, so the only thing preventing
impersonation was the incidental absence of a username claim in link share
tokens. Adding one would have reintroduced the confusion with a positive id,
bypassing the GetID negation entirely.

AuthTypeUser moves to pkg/user, which parses the claims and cannot import
pkg/modules/auth, so the value exists once.
2026-08-04 13:40:11 +02:00
kolaente 4b35f5d7e2 test: assert link share attachments are not attributed to a colliding user
files.Create stores GetID() straight into files.created_by_id, so a share
uploading an attachment used to persist a row indistinguishable from one
created by the user with the same id.
2026-08-04 13:40:11 +02:00
kolaente 2b1558e8df test: cover link share principal confusion at the HTTP layer
A link share JWT reaches every authenticated route; nothing rejects it at the
route group.
2026-08-04 13:40:11 +02:00
kolaente ce0d1355cf fix(security): reject link shares in Webhook.ReadAll
The guard on Webhook.CanRead is unreachable: no route exposes a read-one
webhook, and DoReadAll never calls CanRead. Two paths were left open:

- the v2 user-webhook list passes a.GetID() into Webhook.UserID, which is
  negative for a link share, so the w.UserID > 0 branch and its link share
  check were skipped and the request fell through to the project branch with
  project id 0, returning 404 instead of 403.
- the project branch never rejected link shares at all, so any holder of a
  public share link could list the project's webhooks. target_url is a bearer
  secret for Slack, Discord, Teams and Zapier.

Guard both by rejecting link shares at the top of ReadAll.
2026-08-04 13:40:11 +02:00
kolaente a717d64d56 fix(security): reject link shares at team, bot and webhook permission checks
These checks all compare the auth id against a users.id column, so a link
share whose id collided with a user id passed them as that user.
2026-08-04 13:40:11 +02:00
kolaente 6ffe265efa fix(security): return the negated user id from LinkSharing.GetID
web.Auth is satisfied by both *user.User and *LinkSharing, so returning the
raw positive share.ID made a share with id N indistinguishable from the user
with id N at every permission check comparing against a users.id column.

The rest of the codebase already keys shares negatively
(getUsersOrLinkSharesFromIDs, toUser), so this makes GetID consistent with
that contract instead of an exception to it.
2026-08-04 13:40:11 +02:00
kolaente 7eeb88da23 fix(migration): repair kanban views without a bucket configuration mode
Views broken by the bug above keep their state until something writes
them again, so repair them on startup: set the manual mode, seed the
default buckets when the view has none and place the project's tasks in
the default bucket.

Views whose project or saved filter is gone are skipped rather than
repaired with a dangling creator, soft-deleted tasks stay out of the
backfill, and the mode flip is the last write per view so an
interrupted run picks the view up again.
2026-08-04 01:09:10 +02:00
kolaente 2d8198e942 fix(views): seed and maintain kanban buckets when a view's kind changes
Switching an existing view to kanban left bucket_configuration_mode at
none, so the tasks endpoint returned a flat task list which the
frontend rendered as empty bucket columns.

The mode is now normalized on create and update: a kanban view without
a mode becomes manual, a non-kanban view loses its mode, and an update
which omits the mode keeps the stored one together with its bucket
configuration. Becoming a manual kanban view seeds the default buckets
and backfills task_buckets rows for tasks which have none in that view,
so tasks created while the view was of a different kind stay visible.
Bucket ids from the request are validated against the view: an id of a
bucket which is gone resets to zero instead of locking the view, an id
belonging to another view is rejected, and non-kanban views no longer
write those columns at all so a round trip can restore them.

The backfill selects only task ids, scopes saved filter views to the
projects their owner can see, batches its inserts and ignores conflicts
with concurrently placed tasks.

Fixes https://github.com/go-vikunja/vikunja/issues/3386
2026-08-04 01:09:10 +02:00
Frederick [Bot] 0ad7a4a4db [skip ci] Updated yaegi symbols 2026-08-02 14:37:40 +00:00
kolaente ba980b1b8e fix(migration): abort import on unexpected task creation error
Non-empty-title errors from task creation fell through silently and the
loop kept using a task with ID 0, attaching relations and labels to id
0. Return the error instead; empty titles keep being skipped.
2026-08-02 16:21:50 +02:00
kolaente 57bfd1cae2 feat(api/v2): add bulk task creation endpoint
POST /api/v2/projects/{project}/tasks/bulk creates up to 100 tasks in
one atomic request. The URL project wins over any body project_id, and
rich-text descriptions honor the format query param like single create.
Bucket limits are only enforced for explicitly provided bucket ids.

The route files under the tasks API-token group as create_bulk,
matching the v1 bulk naming, so a token scoped to tasks can use it.
2026-08-02 16:21:50 +02:00
kolaente 42fdacd26d feat(tasks): update saved-filter views once per creation batch
The resource-heavy saved-filter evaluation moves from TaskCreatedEvent
to the batch event: filters, views and the fallback timezone are loaded
once per batch, then every task is evaluated in one pass. Buckets and
positions are inserted per task so a mid-loop position recalculation
sees earlier members' rows.
2026-08-02 16:21:50 +02:00
kolaente e86c17faa9 feat(tasks): add BulkTaskCreation model
Creates up to 100 tasks in one project atomically. One project write
check covers the batch; any invalid task rolls the whole batch back
with error 4031 naming the offending payload index, and an
invalid batch size returns 4030. The 1..100 bound is declared as
minItems/maxItems in the schema (pinned to the constant by a test) with
the model check as backstop for non-HTTP callers. Payload positions are
zeroed — positions are always calculated server-side.
2026-08-02 16:21:50 +02:00
kolaente c385c0e72a feat(tasks): generalize task creation to batches
createTask becomes a wrapper around createTasks, which creates a whole
batch in one pass: project, creator, views and default buckets are
looked up once, indexes are assigned from a single max-index query
(preset indexes kept when free, collisions get the next free one), and
explicitly provided buckets are resolved once per distinct bucket —
verified to belong to the target project and checked against their
limit with the batch's own members counted. The row insert stays per
task because multi-row inserts don't reliably return autoincrement ids
on all supported databases.

Validation errors always carry the payload index; the single-create
wrapper unwraps them so existing callers keep their raw error types.

A new TasksBatchCreatedEvent fires once per batch (single create is a
batch of one) for listeners which can process all new tasks in one
pass; per-task TaskCreatedEvent semantics stay unchanged for webhooks,
mentions and audit.

Split for reviewability: builds together with the follow-up commit
adding the BulkTaskCreation model and error types.
2026-08-02 16:21:50 +02:00
kolaente 852dca94a4 feat(tasks): calculate top-of-view positions per batch
calculateNewPositionsForTasks places a whole creation batch on top of a
view with a single lowest-position query: evenly spaced below the
current lowest, payload order preserved, no collisions by construction.
When the spacing trips a full recalculation, the batch's own freshly
inserted rows are snapshot-scoped and removed again afterwards so the
queued top positions land while rows written earlier in the creation
survive.

It replaces the two previous implementations of the same idea:
calculateNewPositionForTask is now a one-element wrapper and the
saved-filter healing path calls it directly. The empty-view default
falls back to payload-order spacing when task indexes repeat or are
zero, since index-derived defaults collide in saved-filter views
spanning projects.
2026-08-02 16:21:50 +02:00
kolaente 3855517a7a fix(api/v2): validate slice-of-struct body fields at the boundary
govalidator does not recurse into []*T fields, so elements of
slice-of-pointer-struct body fields skipped the valid-tag rules that
nested struct fields get. Validate each element of exported, writable
[]*struct body fields, reporting errors as body.<field>[<index>].<name>
like Huma's own schema errors. readOnly fields are skipped since Huma
deliberately accepts round-tripped values on write.
2026-08-02 16:21:50 +02:00
kolaente a854ad6641 fix(plugins): regenerate stale yaegi symbol tables
The symbol tables in pkg/yaegi_symbols were generated once by hand and
never updated, so newer exported API like models.TimeEntry was invisible
to interpreted plugins.

The logFatal/logFatalf wrappers in symbols.go exist because yaegi
extract treats Fatal* in any package named "log" as restricted and
references those local names; previously the generated file was
hand-patched instead, which made regeneration produce uncompilable
output.

Fixes https://github.com/go-vikunja/vikunja/issues/3387
2026-08-02 16:16:06 +02:00
kolaente 9b40ad9b46 fix(dump): reuse single stdin reader for all restore prompts
Each prompt created its own bufio.Reader over os.Stdin. The first
reader buffers ahead, so with piped input the later readers hit EOF
because the remaining lines were already sitting in the first
reader's buffer.
2026-07-31 20:57:30 +02:00
kolaente 79b241c211 fix(dump): skip directory entries in dump zip when restoring
Vikunja's own dumps only contain file entries, but manually repacked
dumps (e.g. with zip -r after editing the VERSION file) contain
directory entries like "database/", which failed database file name
validation and aborted the restore.

Fixes https://github.com/go-vikunja/vikunja/issues/3380
2026-07-31 20:57:30 +02:00
kolaente 506dbd7b04 fix(caldav): stop corrupting percent signs in REPORT responses 2026-07-30 21:43:05 +02:00
kolaente 0ad2e70b3a fix(dump): parse dumped time strings so restore works on MySQL and MariaDB
Dumps store datetime columns as RFC3339 strings (e.g. 2026-03-27T11:27:01Z).
On restore these were passed verbatim to the database, which MySQL and
MariaDB reject with 'Error 1292 Incorrect datetime value'. Parse them into
time.Time so the driver formats them for the target database.

Also guard against a nil pointer dereference when a dump contains a column
that no longer exists in the current schema - such columns are now dropped
with a warning instead of crashing.

Fixes https://github.com/go-vikunja/vikunja/issues/3375
2026-07-30 21:15:19 +02:00
4abda62a99 fix(labels): allow attaching labels via inherited child-project access (#3374)
Users with access to parent project could remove labels from tasks in
child projects but not add them back — 403 "Tried to create while not
having the permissions for it". Label attach check used direct shares
only; task write permission and label picker both walk project
hierarchy. Fix: label access now uses same recursive subquery.

Reported: https://community.vikunja.io/t/permissions-on-labels/4460

## How to verify

1. As user A, create a parent project with a child project, and add a
task in the child project.
2. Share the parent project with a team that has write access and
contains user B.
3. As user A, add a label to the task in the child project.
4. As user B, open that task, remove the label, then try to add it back.
5. **Expected:** the label can be added again; the API returns 201.

**Before this PR:** step 4 failed with 403 even though user B could edit
the task and remove the label.

Co-authored-by: kolaente <k@knt.li>
2026-07-30 12:03:30 +02:00
kolaente f103889312 fix(caldav): report completion status from Done instead of done_at
A repeating task completed via CalDAV is reopened by the repeat helper but keeps
its done_at, so GET advertised STATUS:COMPLETED for an open task. Clients echoed
that back as done and every sync round-trip rescheduled it another interval.
2026-07-30 11:32:24 +02:00
kolaente f9435bad91 fix(caldav): anchor RELATED=END alarm triggers on due date when the task has one
We emit due- and end-relative reminders both as TRIGGER;RELATED=END, so the anchor
is ambiguous coming back. Before DTEND was parsed, EndDate was always zero and it
resolved to due_date; now a client echoing its own VTODO back silently re-anchors
due-relative reminders to end_date.
2026-07-30 11:32:24 +02:00
kolaente 1b12ee93da fix(models): write back dates rescheduled by repeat logic under restricted column updates
updateDone reschedules due/start/end dates and resets the description checklist
after colsToUpdate was frozen from the caller's field list, so a restricted update
computed the next occurrence and threw it away. Affects BulkTask.Update, the
remaining caller that passes a field list.
2026-07-30 11:32:24 +02:00
kolaente 4c5ec6a4d8 fix(caldav): don't wipe fields the parser doesn't understand on update
CalDAV PUT called unrestricted Task.Update, so it wiped repeat_after/repeat_mode,
percent_done and dates the parsed VTODO didn't carry, plus assignees, reminders,
favorites and relations. Parse now reports which fields the VTODO actually spoke
to and UpdateResource overlays only those onto the stored task, re-read inside the
update transaction. A UID can match several tasks, so the write targets the one the
permission check covered.

Fixes #544
Fixes #1422
2026-07-30 11:32:24 +02:00
kolaente 7e310be332 fix(caldav): remove double trailing slash from current-user-principal href 2026-07-30 00:13:07 +02:00
TinkandGitHub 0b46adfa88 fix(caldav): return 404 for principal sub-paths and foreign usernames (#3371) 2026-07-29 23:44:49 +02:00
TinkandGitHub b487a4099c fix(caldav): answer PROPPATCH with 207/403 instead of a blanket 501 (#3364) 2026-07-29 21:33:02 +00:00
Mauandkolaente a6683b2a6f fix(models): use ILIKE for the team search count query
TeamProject.ReadAll used db.ILIKE for the main query but a
case-sensitive LIKE for the count query, so on postgres the total
count could disagree with the returned rows. Use db.ILIKE for both.
2026-07-29 22:53:46 +02:00
Mauandkolaente d86af5c9b6 fix(avatar): bounds-check size before narrowing int64 to int
GetAvatar converted a caller-supplied size int64 directly to int via
int(size) for imaging.Resize, with no check that the value was
non-negative or within int's range. size traces back to an
unauthenticated query parameter; on 32-bit builds a large int64
would silently truncate/wrap when narrowed, and a negative value
would feed straight into the resize call. Reject out-of-range values
before the conversion instead of letting it wrap silently.
2026-07-29 22:53:46 +02:00
Mauandkolaente eb31ee3780 fix(migration/csv): cap CSV import buffer to the configured upload limit
DetectCSVStructure, PreviewImport, and MigrateWithConfig each did
make([]byte, size) with size taken directly from the caller (an
uploaded file's declared size) with no upper bound, letting a
malicious or corrupt size value force an arbitrarily large
allocation. Cap it to the server's existing configured max upload
size before allocating.
2026-07-29 22:53:46 +02:00
Mauandkolaente 1790727981 fix(openid): stop logging raw token-endpoint response bodies
Three Debug-level log calls in the token-exchange path logged the
raw response body/full oauth2.Token on error. The full token struct
has no String() override, so %v printed AccessToken/RefreshToken in
clear text; the raw response body could similarly carry sensitive
request/response context. Log the RFC 6749 error/error_description
fields (successful-exchange-but-missing-id_token path: the token
type) instead - the intended human-readable diagnostic.
2026-07-29 22:53:46 +02:00
kolaente 9077604829 fix(subscriptions): marshal unknown subscription entity type as null
The fallback returned the literal bytes `nil`, which is invalid JSON.
2026-07-29 22:28:31 +02:00
kolaente 767be216e6 fix(subscriptions): declare subscription entity as string enum in v2 OpenAPI schema
SubscriptionEntityType is an int in Go but its custom MarshalJSON emits
"project"/"task" strings. Huma reflected it as an integer schema, so
AutoPatch's GET-to-PUT echo failed request validation with
"subscription.entity: expected integer" whenever the requesting user had
a task or project subscription, blocking all v2 task and project PATCH
writes for subscribed users.

Add a Schema override reflecting it as a string enum, same pattern as
ProjectViewKind and BucketConfigurationModeKind.

Fixes #3316
2026-07-29 22:28:31 +02:00
Frederick [Bot] 0c2485e11c [skip ci] Updated swagger docs 2026-07-29 08:45:06 +00:00
kolaenteandkolaente de7a039068 fix(notifications): delete stored notifications of unknown types
349e6a590 renamed ListCreatedNotification's name from list.created to
project.created without rewriting the notifications.name column, so instances
upgraded from 0.19 or earlier still hold list.created rows carrying a full
project payload. Nothing can hydrate them, so nothing can scope them either,
and the read paths pass unrecognised names through unfiltered — leaking the
project's title, description, identifier, colour and owner to users since
removed from it.

Retaining rows no type can render only preserves that leak, so they go.
2026-07-29 07:58:17 +00:00
kolaenteandkolaente e983fa10c7 fix(web): stop logging the full auth object on permission denials
log.Warningf formatted a web.Auth with %v on the create, read, update and
delete denial paths. That holds a *user.User, which has a Password field
carrying the bcrypt hash and no String method, so every denied request wrote
a password hash into the log at warning level. Log the user id instead.
2026-07-29 07:58:17 +00:00
kolaenteandkolaente cd9033184b fix(notifications): require a persisted notification to declare its project
The capability interface carrying a notification's project was optional, and
ProjectIDOf defaulted a missing implementation to 0 — which means
account-scoped, which means always visible. So a new project-scoped type whose
author forgot the method would have leaked task titles, project names and
comment bodies to users with no access to the project, with no compile error
and no test failure.

Register now takes a factory returning PersistedNotification, which requires
the method. Registering is what makes a notification persist, so a stored row
that cannot be permission-checked no longer compiles. The three account-scoped
types say so by returning 0 explicitly instead of by omission.
2026-07-29 07:58:17 +00:00
kolaenteandkolaente 0638200ac0 fix(notifications): check project access when reading notifications
Notification rows outlived access. A subscription survives a project being
unshared, so every notification already written for a revoked user stayed
readable — comment bodies, task titles, project names, deletion notices. The
read paths filtered on notifiable_id alone, with no permission check anywhere.
#3325 stopped the sender writing new ones; this is the other half.

The project a notification is about is persisted on the row when it is written
and the read paths filter on it in SQL, so LIMIT, OFFSET and total are all
computed on the filtered set. Notification types declare their project through
a capability interface in pkg/notifications, the same way they already declare
SubjectID, ThreadID and ToTitle — which is what lets the package below
pkg/models stay ignorant of what a project is.

project_id 0 means account-scoped and always visible, a positive value is
checked against the projects the caller can read, and -1 marks a project-scoped
row whose project could not be determined, so it is visible to nobody. Filtering
reuses the existing accessibleProjectIDsSubquery, so the page query and the
count cannot drift apart. A migration backfills existing rows from their stored
payloads, resolving through soft-deleted tasks so task.deleted rows still land
on their project.

Covers every read path: the v1 and v2 list endpoints, mark-as-read (which
echoes the payload back), the Atom feed, and the websocket push — the last of
which is load-bearing, since a row is still written for a revoked subscriber.
Deliberately no instance-admin bypass: notifications are always the caller's
own, and being an admin says nothing about whether they should still read a
comment out of a project they were removed from.
2026-07-29 07:58:17 +00:00
kolaenteandkolaente 1aed84a2b6 test(models): restore notification faking after user delete tests
The user delete tests left the notification backend un-faked, leaking
into whichever test ran next.
2026-07-29 07:33:35 +00:00
kolaenteandkolaente e252954229 fix(notifications): don't notify subscribers who lost access to the entity
Subscriptions outlive access: nothing purges them when a project is
unshared, and access can change with no revocation event at all, so a
user who can no longer open a task kept receiving its comment bodies,
assignment details and deletion notices by mail and in the feed.

Filter subscribers by current read permission when the subscription is
fetched, so every listener is covered by one check. Rows are kept rather
than deleted - a subscription is user intent and resumes if access does.

GetSubscriptionsForDeletedTask keeps its own lookup because a
soft-deleted task cannot be resolved back to its project, but it now
reuses the same filter with the project id it already holds.
2026-07-29 07:33:35 +00:00
kolaenteandkolaente ec4dbb8600 refactor(projects): resolve read permissions for many projects at once
Checking one project per user at a time meant a query per pair. Resolve
them in a batch so callers with a list of projects pay one round trip.
2026-07-29 07:33:35 +00:00
kolaenteandkolaente 1c9626ca2a fix(projects): don't report database errors as a missing project
A failed permission lookup was surfaced as ErrProjectDoesNotExist, so a
database error read as "no such project" to every caller.
2026-07-29 07:33:35 +00:00
kolaente a6edc67876 fix(migration): prevent stack overflow on ticktick parentId cycles
sortParentsBeforeChildren marked a task as placed only after recursing
into its parent, so a parentId cycle in an uploaded TickTick export made
place() recurse forever. That is a Go stack overflow, a runtime fatal
error the recover middleware cannot catch, so a two-line CSV from any
authenticated user took down the whole process.

Track a tri-state per task and mark it before recursing, which breaks
the cycle. Acyclic input is unaffected.
2026-07-29 09:22:22 +02:00
TinkandGitHub c4e40567b4 fix(migration): detect sqlite indexes created with lowercase SQL (#3354) 2026-07-29 09:21:25 +02:00
kolaente 82a29d4c93 fix(filter): don't rewrite in/not in/like inside quoted values
The filter preprocessing replaced " in ", " not in " and " like " with their
fexpr sigils using blind whole-string replacements, corrupting any value that
happened to contain those words: `title like 'stuff in progress'` became
`title ~ 'stuff ?= progress'`, so the filter matched the wrong tasks or failed
to parse with no hint as to why.

Walk the filter instead and skip over quoted runs, matching fexpr's own
scanner: both ' and " open a string and a backslash escapes the next
character. An unclosed quote is treated as an ordinary character so bare
values with an apostrophe keep working. " not in " is still matched before
" in " so the longer operator wins.
2026-07-28 23:41:49 +02:00