Replace the github.com/spf13/afero dependency with a purpose-built
FileStorage interface (Open, Write, Stat, Remove, MkdirAll) with three
implementations: localStorage (with basePath), s3Storage (with key
prefix), and memStorage (for tests).
Each implementation owns its base path — callers pass only file IDs.
Delete s3fs.go, change File.File from afero.File to io.ReadCloser,
and fix duplication flows to buffer content for seeking.
http.ServeContent requires io.ReadSeeker which S3 files don't support.
Add S3 branch using io.Copy with explicit headers, matching the pattern
already used in task attachment downloads.
Refs: #2347
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the third-party afero-s3 library (and its temporary fork) with
a minimal in-tree afero.Fs implementation (~200 lines) that only supports
the three S3 operations Vikunja actually uses: Open (GetObject), Remove
(DeleteObject), and Stat (HeadObject).
The s3File implementation lazily opens a GetObject stream on first Read
and supports Seek by closing and re-opening the stream from the new offset,
matching the behavior of the fixed afero-s3 fork.
All other afero.Fs/File methods return ErrS3NotSupported since they are
never called in the S3 code path (writes already use direct PutObject).
This removes the fclairamb/afero-s3 dependency and the temporary replace
directive in go.mod entirely.
Closes#2347
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolves issue #475 by modifying CalDAV discovery so Apple Reminders can
use /dav/projects/ as the home set without exposing that synthetic path
as a real task list, preserving the existing principal-based flow. This
is because Apple Reminders defaults back to the /dav/projects/ URL,
rather than accepting the /dav/principals/username/ URL specified in
Vikunja.
Resolves#475
Block webhook requests to non-globally-routable IP addresses by default.
Uses net.Dialer.Control hook to validate resolved IPs against IANA
Special Purpose Registries after DNS resolution, preventing DNS rebinding.
Configurable via webhooks.allownonroutableips (default: false).
When a user enables two factor authentication, all existing sessions are
now invalidated, requiring re-authentication. This prevents pre-existing
sessions from bypassing 2FA. The frontend now shows a notice explaining
the logout before the user confirms, and properly logs out after enabling.
Ref: GHSA-pgc7-cmvg-mvp4
The `expand` query parameter only supported the `expand[]=foo` array
format, but the swagger docs described it as a plain string parameter.
This adds support for both formats (`expand=foo` and `expand[]=foo`),
matching the existing pattern used by `sort_by` and `order_by`
parameters.
Closes#2408
---------
Co-authored-by: kolaente <k@knt.li>
- Fixes `removeStaleRelations` in CalDAV storage provider to only remove
relations of kinds explicitly declared in the incoming VTODO's
`RELATED-TO` properties
- When a VTODO has no `RELATED-TO` at all (e.g., a parent task from
Tasks.org), no relations are removed — they were auto-created as
inverses by child tasks
- When a VTODO declares specific relation kinds (e.g.,
`RELATED-TO;RELTYPE=PARENT`), only relations of that kind are checked
for staleness; other kinds (like auto-created `subtask` inverses) are
preserved
Fixes#2383
---------
Co-authored-by: kolaente <k@knt.li>
The tasks_labels_bulk route was not recognized as a CRUD route by
isStandardCRUDRoute, causing it to be processed as a non-CRUD route
and registered in the wrong apiTokenRoutes group. API tokens with
tasks_labels permissions could not access the bulk endpoint, resulting
in a 401 error.
Fixes https://github.com/go-vikunja/vikunja/issues/2375
When deleting a user via CLI (`vikunja user delete <id> -n`), the user
row was deleted first, then `notifications.Notify` was called. But
`Notify` calls `User.ShouldNotify()` which queries the database to check
the user's status — and since the row was already deleted within the same
transaction, it returned `ErrUserDoesNotExist`.
Move the notification call before the `DELETE` so the user row still
exists when `ShouldNotify` checks it.
Closesgo-vikunja/vikunja#2335
When running as a systemd service, the binary is often in /usr/local/bin
but WorkingDirectory points to a data directory like /var/lib/vikunja.
Previously, rootpath defaulted to the binary's directory, causing
Vikunja to attempt creating files/db in /usr/local/bin.
Now os.Getwd() is preferred, which respects systemd's WorkingDirectory
and is the intuitive default for all deployment scenarios.
Add comprehensive e2e tests for user-level webhook CRUD operations
and update existing project webhook tests to use LoadFixtures() for
cleanup instead of manual DELETE queries.
Add CRUD endpoints for user-level webhooks under /user/settings/webhooks.
Users can create webhooks that fire on user-directed events (task.reminder.fired,
task.overdue, tasks.overdue). Includes proper permission checks via
canDoWebhook which loads webhook ownership from DB.
Add User field to reminder and overdue events so the webhook listener
can look up user-level webhooks. Add conditional user filtering to
reminder and overdue cron jobs - when only email is enabled, filter to
email-enabled users; when webhooks are enabled, fetch all users so
events can be dispatched. Dispatch TasksOverdueEvent and
TaskReminderFiredEvent for webhook consumption.
Add user_id column to webhooks table (nullable, for user-level webhooks
vs project-level). Extend webhook model, permissions, and listener to
support user-level webhooks that fire for user-directed events like
task reminders and overdue task notifications.
Add TasksOverdueEvent for dispatching overdue notifications via webhooks.
Update webhook permissions to handle both user-level and project-level
ownership. Add webhook test fixture and register webhooks table in test
fixture loader.
Add tests for conversational header generation, HTML rendering
with p-tag wrapping, plain-text conversion, footer handling,
and conditional action links. Update mention test fixtures
with Project field.
Add action strings with doer names for comments, mentions,
assignments, and reminders. Add notification settings footer
and task identifier format strings.
Convert task comment, mention, assignment, and reminder notifications
to use the conversational email format. Add Project field to notification
structs, include task identifiers in subjects and headers, add doer
avatars, notification settings links in footers, and From headers.
Add conversational email style with GitHub-inspired header design.
Includes mail struct extensions (headerLine, conversational flag),
CreateConversationalHeader helper, HTML template with avatar support,
p-tag wrapping for content lines, plain-text stripping, and
conditional action link rendering.
The current implementation of the user confirmation when deleting a user
using the CLI seems to favour Linux. On Windows the <ENTER> command adds
"\r\n" to the user input while Linux only adds "\n".
I have added an extra check to the confirmation, but GO is a new
language for me, so there is probably a much better way to do this.
---------
Co-authored-by: kolaente <k@knt.li>
Address review feedback: assert exact result counts when ParadeDB is
active. fuzzy(1, prefix=true) broadens matches via edit distance,
returning 6 projects for "TEST10", 14 tasks for "number #17", and
12 projects for "Test1".
- Use require.NotEmpty instead of require.Greater for testifylint
- Skip exclusion assertions in web project search test when ParadeDB is
active, since fuzzy(1, prefix=true) on "Test1" also matches Test2, Test3
ParadeDB fuzzy(1, prefix=true) returns more results than ILIKE due to
edit-distance tolerance on tokenized terms. Adjust assertions to check
containment rather than exact result sets when ParadeDB is active.
The new fixture task #48 (Landingpages update, project 1) needs to
appear in all feature test expected result sets that list project 1
tasks. Also bumps the expected next index in TestTask_Create.
Instead of manually constructing builder.Expr conditions to simulate
the ParadeDB path, call MultiFieldSearchWithTableAlias() directly and
use isParadeDB() to branch assertions. When ParadeDB is available,
assert the ||| operator with ::pdb.fuzzy(1, t) syntax; otherwise
assert the LIKE/ILIKE fallback. Tests skip when no database engine
is initialized (x == nil).
Switch from legacy @@@ paradedb.match() to v2 ||| operator with
::pdb.fuzzy(1, t) cast. This enables prefix matching so 'landing'
matches 'landingpages', and adds single-character typo tolerance.
New pkg/e2etests/ package runs with the real Watermill event system to
verify the full async pipeline: web handler -> DB -> event dispatch ->
Watermill -> listener -> side effect.
First test (TestTaskUpdateWebhookE2E) updates a task and asserts that
a webhook HTTP POST arrives at a test server.
InitEventsForTesting sets up a real Watermill GoChannel router with a
cancellable context, returning a readiness channel. Skips Prometheus
metrics and poison queue to avoid duplicate registration panics.
Unfake re-enables real event dispatch after test init helpers call Fake().
Also guards Dispatch against nil pubsub with a clear error message.
Use a separate error variable for the user lookup in
UpdateTaskInSavedFilterViews so that ErrUserDoesNotExist does not
pollute the named return `err`. The `:=` inside the for loop shadowed
the outer `err`, leaving it set to the stale user-not-found error,
which caused the handler to be poisoned.
Closes#2359
The ListUsers function now references team_members and teams tables
via a subquery for external team discoverability. The pkg/user test
environment only syncs user tables, so these tests need to run in
pkg/models which has the full schema and all fixtures.
Also adds new tests for the external team discoverability bypass
directly in the models package alongside the moved tests.
Track old-to-new attachment ID mapping during duplication and
re-set CoverImageAttachmentID on the new task if the original
had a cover image configured.
Save the source file handle before calling NewAttachment (which
overwrites attachment.File) and use defer to ensure it gets closed.
This prevents file descriptor leaks on both success and error paths.
Previously WipeEverything used x.DBMetas() which returns all tables
in the database, including those owned by PostgreSQL extensions like
PostGIS. This caused restore to fail with 'cannot drop table
spatial_ref_sys because extension postgis requires it'.
Now uses the registered table list instead.
Fixesgo-vikunja/vikunja#2219
When a task bucket is updated without changing buckets (early return in
updateTaskBucket), b.Task remains nil. The TaskUpdatedEvent was then
dispatched with a nil Task, causing a nil pointer dereference in
HandleTaskUpdatedMentions when accessing event.Task.Description.
This adds:
- A nil guard in TaskBucket.Update to skip event dispatch when b.Task is nil
- Nil checks in HandleTaskUpdatedMentions, HandleTaskCreateMentions,
HandleTaskCommentEditMentions, and UpdateTaskInSavedFilterViews
- Tests verifying the handlers gracefully handle nil task events
Closes#2351
CalDAV handlers manage their own database sessions. Now that model
methods use DispatchOnCommit, the CalDAV handlers must call
DispatchPending after commit and CleanupPending on rollback.
Refs #2315
Convert events.Dispatch to events.DispatchOnCommit in team and team
member CRUD. Also removes premature s.Commit() from TeamMember.Delete
since the handler manages the transaction lifecycle.
Refs #2315
Convert events.Dispatch to events.DispatchOnCommit in Task.Create,
updateSingleTask, Task.Delete, and triggerTaskUpdatedEventForTaskID.
Events are now dispatched by the handler after s.Commit(), ensuring
webhook listeners see committed data.
Fixes#2315
All generic CRUD handlers now call events.DispatchPending(s) after
s.Commit() and events.CleanupPending(s) on rollback paths. This is
preparation for switching model methods from events.Dispatch to
events.DispatchOnCommit.
Refs #2315
Events dispatched inside model methods run before the transaction commits,
causing listeners (especially webhooks) that open new sessions to read
stale data. These new functions allow accumulating events during a
transaction and dispatching them only after commit.
Refs #2315
Async event handlers (via Watermill) from the previous test can hold
SQLite connections, starving the next test's fixture setup PATCH request.
Three changes fix this:
1. Track in-flight event handler goroutines with a WaitGroup.
2. Call WaitForPendingHandlers() in the test endpoint before
truncating/inserting data.
3. Navigate the browser to about:blank in fixture teardown to stop
notification polling and other frontend requests between tests.
checkUserCaldavTokens called user.GetCaldavTokens which creates its own
db.NewSession(), while the caller (BasicAuth) already holds an open
session. With SQLite this caused a deadlock because the second session
blocks on the write lock held by the first session in the same goroutine.
Add GetCaldavTokensWithSession that accepts an existing session and use
it from checkUserCaldavTokens.
SameSite=None requires Secure=true per browser spec. When running over
plain HTTP (local dev, e2e tests), browsers reject or downgrade the
cookie, breaking session refresh. Fall back to SameSite=Lax for HTTP
while keeping SameSite=None for HTTPS (needed for the Electron desktop
app cross-origin scenario).
Three SQLite connection issues are fixed:
1. The refactoring in 26c0f71 accidentally dropped _busy_timeout from
the file-based SQLite connection string. Without it, concurrent
transactions get instant SQLITE_BUSY errors instead of waiting.
2. _txlock=immediate forced ALL transactions (including reads) to
acquire the write lock at BEGIN, serializing all database access.
WAL mode makes this unnecessary: readers use snapshots and never
block writers, so the SHARED-to-RESERVED deadlock cannot occur.
3. In-memory shared cache (file::memory:?cache=shared) uses table-level
locking where _busy_timeout is ineffective (returns SQLITE_LOCKED,
not SQLITE_BUSY) and concurrent connections deadlock. Replace with a
temp file using WAL mode for proper concurrency.
MaxOpenConns(1) caused Go-level deadlocks: when two goroutines needed
database connections concurrently, the second blocked forever waiting
for the single connection pool slot. This broke CI (sqlite web tests
timed out after 45min, e2e tests hung).
The actual "database is locked" errors were caused by SQLite's default
deferred transaction locking: two connections both acquire SHARED locks,
then deadlock when both try to promote to RESERVED for writing. SQLite
detects this instantly and returns SQLITE_BUSY, bypassing busy_timeout.
_txlock=immediate fixes this by acquiring the write lock at BEGIN time.
The second concurrent transaction waits (up to busy_timeout) instead of
deadlocking. Combined with WAL mode (concurrent readers + single writer),
this handles concurrency correctly without restricting the Go connection
pool.
SameSite=Strict prevents the browser from sending the HttpOnly refresh
token cookie in cross-origin contexts like the Electron desktop app,
where the page runs on localhost but the API is on a remote host. This
caused sessions to expire quickly because refresh requests never
included the cookie.
SameSite=None allows cross-origin sending while HttpOnly still prevents
JavaScript from reading the cookie value (XSS protection).
Resolves#2309
Configure SQLite connections with WAL journal mode, a 5-second busy
timeout, shared cache, and a max of 1 open connection. SQLite only
supports a single writer at a time, so without these settings concurrent
API requests (e.g. bulk task creation) would immediately fail with
"database is locked" instead of waiting and retrying.
calculateNewPositionForTask only checked for lowestPosition == 0 before
triggering a full position recalculation. Extremely small position
values (e.g. 3.16e-285) passed this check, causing new tasks to get
meaningless positions via the index * 2^16 fallback, breaking sort
order.
Use the same < MinPositionSpacing threshold that
createPositionsForTasksInView and the position update handler already
use.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When creating a new view without specifying a position, it defaulted to
0, causing it to always sort before all other views. Apply
calculateDefaultPosition to assign a unique position based on the view
ID, consistent with how projects, tasks, and buckets handle this.
Fixesgo-vikunja/vikunja#2319