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
This commit is contained in:
Henry Cole 2026-03-20 09:33:56 +00:00 committed by GitHub
parent 9c3fa8e91b
commit e7f1e99878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 127 additions and 7 deletions

View File

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

View File

@ -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) {

View File

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

View File

@ -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 := `<?xml version="1.0" encoding="utf-8" ?>
<A:propfind xmlns:A="DAV:" xmlns:B="urn:ietf:params:xml:ns:caldav">
<A:prop>
<A:current-user-principal />
<B:calendar-home-set />
<A:resourcetype />
</A:prop>
</A:propfind>`
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, "<d:href>/dav/projects/</d:href>")
assert.NotContains(t, responseBody, "<D:href>/dav/projects/</D:href>")
})
t.Run("Project home set depth 0 returns the home set itself", func(t *testing.T) {
e, _ := setupTestEnv()
propfindBody := `<?xml version="1.0" encoding="utf-8" ?>
<A:propfind xmlns:A="DAV:" xmlns:B="urn:ietf:params:xml:ns:caldav">
<A:prop>
<A:current-user-principal />
<B:calendar-home-set />
<A:resourcetype />
</A:prop>
</A:propfind>`
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 := `<?xml version="1.0" encoding="utf-8" ?>
<A:propfind xmlns:A="DAV:" xmlns:B="urn:ietf:params:xml:ns:caldav">
<A:prop>
<A:current-user-principal />
<B:calendar-home-set />
</A:prop>
</A:propfind>`
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, "<D:href>/dav/projects</D:href>")
assert.NotContains(t, responseBody, "/dav//projects/")
})
}
func TestCaldavSubtasks(t *testing.T) {
const vtodoHeader = `BEGIN:VCALENDAR
VERSION:2.0