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.
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
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
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>
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.
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.
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.
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#544Fixes#1422
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.
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.
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.
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.
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
Creating it at startup meant two renderers ran checkAuth() concurrently
and raced over the single-use refresh token. showQuickEntry() already
creates it on demand, including for the --quick-entry launch flag.
The desktop branch of doRefresh() returned before the Web Locks section,
so the main window and the quick entry window could both POST the same
single-use OAuth refresh token on startup. The server rotates it on first
use, so the losing window got a 401 and logged the user out.
Move the desktop branch inside the lock and adopt the JWT another window
already fetched when the stored refresh token changed while queued.
Fixes https://github.com/go-vikunja/vikunja/issues/3275
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.
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.
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.
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.
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.
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.
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.
ProjectInfo re-permits the target attribute via ADD_ATTR, so a link in a
project description could open a page that keeps a live window.opener
handle to the Vikunja tab and navigate it elsewhere.
Force rel via an afterSanitizeAttributes hook rather than allowing it
through ADD_ATTR, so a description supplying its own rel cannot drop
noopener.