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()