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