From e7f1e99878a9511ff9ca800eaa5e1ea007561881 Mon Sep 17 00:00:00 2001 From: Henry Cole Date: Fri, 20 Mar 2026 09:33:56 +0000 Subject: [PATCH] fix(caldav): use /dav/projects/ as home to make iOS/MacOS reminders work (#2417) 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 --- pkg/routes/caldav/handler.go | 6 +- pkg/routes/caldav/listStorageProvider.go | 36 ++++++++-- pkg/routes/routes.go | 1 + pkg/webtests/caldav_test.go | 91 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/pkg/routes/caldav/handler.go b/pkg/routes/caldav/handler.go index c4d3e14fb..395313a5e 100644 --- a/pkg/routes/caldav/handler.go +++ b/pkg/routes/caldav/handler.go @@ -82,7 +82,7 @@ func ProjectHandler(c *echo.Context) error { log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header) caldav.SetupStorage(storage) - caldav.SetupUser("dav/projects") + caldav.SetupUser(strings.TrimPrefix(ProjectHomeSetPath, "/")) caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO}) response := caldav.HandleRequest(c.Request()) response.Write(c.Response()) @@ -137,7 +137,7 @@ func PrincipalHandler(c *echo.Context) error { log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header) caldav.SetupStorage(storage) - caldav.SetupUser("dav/principals/" + u.Username) + caldav.SetupUser(strings.TrimPrefix(principalPathForUser(u.Username), "/")) caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO}) response := caldav.HandleRequest(c.Request()) @@ -166,7 +166,7 @@ func EntryHandler(c *echo.Context) error { log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header) caldav.SetupStorage(storage) - caldav.SetupUser("dav/principals/" + u.Username) + caldav.SetupUser(strings.TrimPrefix(principalPathForUser(u.Username), "/")) caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO}) response := caldav.HandleRequest(c.Request()) diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index d73eb4fe0..01d8fcbed 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -35,10 +35,16 @@ import ( ) // DavBasePath is the base url path -const DavBasePath = `/dav/` +const DavBasePath = `/dav` // ProjectBasePath is the base path for all projects resources -const ProjectBasePath = DavBasePath + `projects` +const ProjectBasePath = DavBasePath + `/projects` + +// PrincipalBasePath is the base path for all principal resources +const PrincipalBasePath = DavBasePath + `/principals` + +// ProjectHomeSetPath is the CalDAV home-set path Apple clients use after discovery. +const ProjectHomeSetPath = ProjectBasePath + `/` // VikunjaCaldavProjectStorage represents a project storage type VikunjaCaldavProjectStorage struct { @@ -68,7 +74,7 @@ func (vcls *VikunjaCaldavProjectStorage) GetResources(rpath string, withChildren // and not /dav/projects. I'm not sure if thats a bug in the client or in caldav-go. if vcls.isEntry { - r := data.NewResource(rpath, &VikunjaProjectResourceAdapter{ + r := data.NewResource(withTrailingSlash(rpath), &VikunjaProjectResourceAdapter{ isPrincipal: true, isCollection: true, }) @@ -77,7 +83,7 @@ func (vcls *VikunjaCaldavProjectStorage) GetResources(rpath string, withChildren // If the request wants the principal url, we'll return that and nothing else if vcls.isPrincipal { - r := data.NewResource(DavBasePath+`/projects/`, &VikunjaProjectResourceAdapter{ + r := data.NewResource(ProjectHomeSetPath, &VikunjaProjectResourceAdapter{ isPrincipal: true, isCollection: true, }) @@ -128,6 +134,14 @@ func (vcls *VikunjaCaldavProjectStorage) GetResources(rpath string, withChildren } projects := theprojects.([]*models.Project) + if !withChildren { + r := data.NewResource(withTrailingSlash(rpath), &VikunjaProjectResourceAdapter{ + isPrincipal: true, + isCollection: true, + }) + return []data.Resource{r}, nil + } + var resources []data.Resource for _, l := range projects { rr := VikunjaProjectResourceAdapter{ @@ -144,6 +158,20 @@ func (vcls *VikunjaCaldavProjectStorage) GetResources(rpath string, withChildren return resources, nil } +func withTrailingSlash(path string) string { + if path == "" { + return ProjectHomeSetPath + } + if strings.HasSuffix(path, "/") { + return path + } + return path + "/" +} + +func principalPathForUser(username string) string { + return withTrailingSlash(PrincipalBasePath + `/` + username) +} + // GetResourcesByList fetches a list of resources from a slice of paths func (vcls *VikunjaCaldavProjectStorage) GetResourcesByList(rpaths []string) (resources []data.Resource, err error) { diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index be040f349..d51b78346 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -806,6 +806,7 @@ func registerCalDavRoutes(c *echo.Group) { // THIS is the entry point for caldav clients, otherwise projects will show up double c.Any("", caldav.EntryHandler) c.Any("/", caldav.EntryHandler) + c.Any("/principals/*", caldav.PrincipalHandler) c.Any("/principals/*/", caldav.PrincipalHandler) c.Any("/projects", caldav.ProjectHandler) c.Any("/projects/", caldav.ProjectHandler) diff --git a/pkg/webtests/caldav_test.go b/pkg/webtests/caldav_test.go index 98950e380..0445a62ca 100644 --- a/pkg/webtests/caldav_test.go +++ b/pkg/webtests/caldav_test.go @@ -27,6 +27,7 @@ import ( "code.vikunja.io/api/pkg/routes/caldav" ics "github.com/arran4/golang-ical" + "github.com/labstack/echo/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,6 +86,96 @@ END:VCALENDAR` }) } +func TestCaldavDiscovery(t *testing.T) { + t.Run("Project home set depth 1 includes child projects but not itself", func(t *testing.T) { + e, _ := setupTestEnv() + + propfindBody := ` + + + + + + +` + + c, rec := createRequest(e, "PROPFIND", propfindBody, nil, nil) + c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextXML) + c.Request().Header.Set("Depth", "1") + c.Request().URL.Path = caldav.ProjectBasePath + "/" + c.Request().RequestURI = caldav.ProjectBasePath + "/" + + result, _ := caldav.BasicAuth(c, testuser15.Username, "12345678") + require.True(t, result) + + err := caldav.ProjectHandler(c) + require.NoError(t, err) + assert.Equal(t, 207, rec.Result().StatusCode) + + responseBody := rec.Body.String() + assert.Contains(t, responseBody, "/dav/projects/36") + assert.NotContains(t, responseBody, "/dav/projects/") + assert.NotContains(t, responseBody, "/dav/projects/") + }) + + t.Run("Project home set depth 0 returns the home set itself", func(t *testing.T) { + e, _ := setupTestEnv() + + propfindBody := ` + + + + + + +` + + c, rec := createRequest(e, "PROPFIND", propfindBody, nil, nil) + c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextXML) + c.Request().Header.Set("Depth", "0") + c.Request().URL.Path = caldav.ProjectBasePath + "/" + c.Request().RequestURI = caldav.ProjectBasePath + "/" + + result, _ := caldav.BasicAuth(c, testuser15.Username, "12345678") + require.True(t, result) + + err := caldav.ProjectHandler(c) + require.NoError(t, err) + assert.Equal(t, 207, rec.Result().StatusCode) + + responseBody := rec.Body.String() + assert.Contains(t, responseBody, "/dav/projects/") + }) + + t.Run("Principal discovery points to normalized project home set path", func(t *testing.T) { + e, _ := setupTestEnv() + + propfindBody := ` + + + + + +` + + c, rec := createRequest(e, "PROPFIND", propfindBody, nil, nil) + c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextXML) + c.Request().URL.Path = caldav.PrincipalBasePath + "/user15/" + c.Request().RequestURI = caldav.PrincipalBasePath + "/user15/" + + result, _ := caldav.BasicAuth(c, testuser15.Username, "12345678") + require.True(t, result) + + err := caldav.PrincipalHandler(c) + require.NoError(t, err) + assert.Equal(t, 207, rec.Result().StatusCode) + + responseBody := rec.Body.String() + assert.Contains(t, responseBody, "/dav/projects") + assert.NotContains(t, responseBody, "/dav//projects/") + }) +} + func TestCaldavSubtasks(t *testing.T) { const vtodoHeader = `BEGIN:VCALENDAR VERSION:2.0