From 8e8ffac0166498fcc0ff61227f07345ab6f2b79e Mon Sep 17 00:00:00 2001
From: surfingbytes <94438335+surfingbytes@users.noreply.github.com>
Date: Thu, 26 Mar 2026 11:42:39 +0100
Subject: [PATCH] fix(caldav): add tags and sync token to collections (#2482)
Fixes #2401
---
go.mod | 2 +-
go.sum | 4 +-
pkg/routes/caldav/listStorageProvider.go | 24 ++---
pkg/webtests/caldav_test.go | 130 +++++++++++++++++++++++
4 files changed, 144 insertions(+), 16 deletions(-)
diff --git a/go.mod b/go.mod
index b4bb59ec7..186ef47f5 100644
--- a/go.mod
+++ b/go.mod
@@ -208,4 +208,4 @@ tool (
src.techknowlogick.com/xgo
)
-replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6 and https://github.com/samedi/caldav-go/pull/7
+replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible
diff --git a/go.sum b/go.sum
index 9bc63607b..35ba7f798 100644
--- a/go.sum
+++ b/go.sum
@@ -328,8 +328,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible h1:q7DbyV+sFjEoTuuUdRDNl2nlyfztkZgxVVCV7JhzIkY=
-github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
+github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible h1:81Hr6g9bunxXhRv4AZv0anKcS1WwHLMgo6wbBjamJlY=
+github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go
index 01d8fcbed..5fe5f4e0b 100644
--- a/pkg/routes/caldav/listStorageProvider.go
+++ b/pkg/routes/caldav/listStorageProvider.go
@@ -662,15 +662,6 @@ func (vlra *VikunjaProjectResourceAdapter) IsCollection() bool {
// CalculateEtag returns the etag of a resource
func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string {
- // If we're updating a task, the client sends the etag of the project instead of the one from the task.
- // And therefore, updating the task fails since these etags don't match.
- // To fix that, we use this extra field to determine if we're currently updating a task and return the
- // etag of the project instead.
- // if vlra.project != nil {
- // return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated, 10) + `"`
- // }
-
- // Return the etag of a task if we have one
if vlra.task != nil {
return `"` + strconv.FormatInt(vlra.task.ID, 10) + `-` + strconv.FormatInt(vlra.task.Updated.Unix(), 10) + `"`
}
@@ -679,10 +670,17 @@ func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string {
return ""
}
- // This also returns the etag of the project, and not of the task,
- // which becomes problematic because the client uses this etag (= the one from the project) to make
- // Requests to update a task. These do not match and thus updating a task fails.
- return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated.Unix(), 10) + `"`
+ // For collections, use the latest modification time across all tasks
+ // so that the etag (and derived ctag/sync-token) changes whenever
+ // any task in the project is added, modified, or deleted.
+ latest := vlra.project.Updated
+ for _, t := range vlra.projectTasks {
+ if t.Updated.After(latest) {
+ latest = t.Updated
+ }
+ }
+
+ return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(latest.Unix(), 10) + `"`
}
// GetContent returns the content string of a resource (a task in our case)
diff --git a/pkg/webtests/caldav_test.go b/pkg/webtests/caldav_test.go
index f0aa461d6..fb4eb89e5 100644
--- a/pkg/webtests/caldav_test.go
+++ b/pkg/webtests/caldav_test.go
@@ -662,6 +662,136 @@ END:VCALENDAR`
})
}
+func TestCaldavCollectionProperties(t *testing.T) {
+ t.Run("PROPFIND returns getetag, getctag, and sync-token for collections", func(t *testing.T) {
+ e, _ := setupTestEnv()
+
+ propfindBody := `
+
+
+
+
+
+
+
+`
+
+ rec, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
+ nil, map[string]string{"project": "36"})
+ require.NoError(t, err)
+ assert.Equal(t, 207, rec.Result().StatusCode)
+
+ responseBody := rec.Body.String()
+
+ type Propstat struct {
+ Prop struct {
+ Getetag string `xml:"getetag"`
+ Getctag string `xml:"http://calendarserver.org/ns/ getctag"`
+ SyncToken string `xml:"sync-token"`
+ Displayname string `xml:"displayname"`
+ } `xml:"prop"`
+ Status string `xml:"status"`
+ }
+ type Response struct {
+ Href string `xml:"href"`
+ Propstats []Propstat `xml:"propstat"`
+ }
+ type Multistatus struct {
+ Responses []Response `xml:"response"`
+ }
+
+ var multistatus Multistatus
+ err = xml.Unmarshal([]byte(responseBody), &multistatus)
+ require.NoError(t, err)
+
+ require.NotEmpty(t, multistatus.Responses, "Should have at least one response")
+
+ collectionResp := multistatus.Responses[0]
+
+ var foundEtag, foundCtag, foundSyncToken bool
+ for _, ps := range collectionResp.Propstats {
+ if strings.Contains(ps.Status, "200") {
+ if ps.Prop.Getetag != "" {
+ foundEtag = true
+ assert.Contains(t, ps.Prop.Getetag, "36-", "Collection etag should contain the project ID")
+ }
+ if ps.Prop.Getctag != "" {
+ foundCtag = true
+ assert.Contains(t, ps.Prop.Getctag, "36-", "Collection ctag should contain the project ID")
+ }
+ if ps.Prop.SyncToken != "" {
+ foundSyncToken = true
+ assert.Contains(t, ps.Prop.SyncToken, "data:,", "Sync token should be a data: URI")
+ }
+ }
+ }
+
+ assert.True(t, foundEtag, "Collection should have getetag")
+ assert.True(t, foundCtag, "Collection should have getctag")
+ assert.True(t, foundSyncToken, "Collection should have sync-token")
+ })
+
+ t.Run("Each collection has a unique etag", func(t *testing.T) {
+ e, _ := setupTestEnv()
+
+ propfindBody := `
+
+
+
+
+`
+
+ rec1, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
+ nil, map[string]string{"project": "36"})
+ require.NoError(t, err)
+ assert.Equal(t, 207, rec1.Result().StatusCode)
+
+ rec2, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
+ nil, map[string]string{"project": "38"})
+ require.NoError(t, err)
+ assert.Equal(t, 207, rec2.Result().StatusCode)
+
+ type Propstat struct {
+ Prop struct {
+ Getetag string `xml:"getetag"`
+ } `xml:"prop"`
+ Status string `xml:"status"`
+ }
+ type Response struct {
+ Href string `xml:"href"`
+ Propstats []Propstat `xml:"propstat"`
+ }
+ type Multistatus struct {
+ Responses []Response `xml:"response"`
+ }
+
+ var ms1, ms2 Multistatus
+ err = xml.Unmarshal(rec1.Body.Bytes(), &ms1)
+ require.NoError(t, err)
+ err = xml.Unmarshal(rec2.Body.Bytes(), &ms2)
+ require.NoError(t, err)
+
+ require.NotEmpty(t, ms1.Responses)
+ require.NotEmpty(t, ms2.Responses)
+
+ var etag1, etag2 string
+ for _, ps := range ms1.Responses[0].Propstats {
+ if strings.Contains(ps.Status, "200") && ps.Prop.Getetag != "" {
+ etag1 = ps.Prop.Getetag
+ }
+ }
+ for _, ps := range ms2.Responses[0].Propstats {
+ if strings.Contains(ps.Status, "200") && ps.Prop.Getetag != "" {
+ etag2 = ps.Prop.Getetag
+ }
+ }
+
+ assert.NotEmpty(t, etag1, "Project 36 should have an etag")
+ assert.NotEmpty(t, etag2, "Project 38 should have an etag")
+ assert.NotEqual(t, etag1, etag2, "Different projects should have different etags")
+ })
+}
+
func TestCaldavProjectReport(t *testing.T) {
t.Run("REPORT calendar-query returns all tasks", func(t *testing.T) {
e, _ := setupTestEnv()