From 46d5e862788ebf670809d2dabe7b3db5e3f3d85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 22 Feb 2024 21:19:58 +0100 Subject: [PATCH] caldav: add support for more props on MKCOL and PROPFIND --- caldav/caldav.go | 2 + caldav/elements.go | 33 ++++- caldav/server.go | 23 +++- caldav/server_test.go | 311 +++++++++++++++++++++++++++++++++++++++--- go.mod | 1 + go.sum | 16 +++ 6 files changed, 361 insertions(+), 25 deletions(-) diff --git a/caldav/caldav.go b/caldav/caldav.go index 02705ef..21040c9 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -67,8 +67,10 @@ type Calendar struct { Path string Name string Description string + Color string MaxResourceSize int64 SupportedComponentSet []string + Timezone string } type CalendarCompRequest struct { diff --git a/caldav/elements.go b/caldav/elements.go index 5759f31..cf0c5e8 100644 --- a/caldav/elements.go +++ b/caldav/elements.go @@ -21,8 +21,16 @@ var ( calendarQueryName = xml.Name{namespace, "calendar-query"} calendarMultigetName = xml.Name{namespace, "calendar-multiget"} - calendarName = xml.Name{namespace, "calendar"} - calendarDataName = xml.Name{namespace, "calendar-data"} + calendarName = xml.Name{namespace, "calendar"} + calendarDataName = xml.Name{namespace, "calendar-data"} + calendarColorName = xml.Name{ + Space: "http://apple.com/ns/ical/", + Local: "calendar-color", + } + calendarTimezoneName = xml.Name{ + Space: namespace, + Local: "calendar-timezone", + } ) // https://tools.ietf.org/html/rfc4791#section-6.2.1 @@ -41,6 +49,16 @@ type calendarDescription struct { Description string `xml:",chardata"` } +type calendarColor struct { + XMLName xml.Name `xml:"http://apple.com/ns/ical/ calendar-color"` + Color string `xml:",chardata"` +} + +type calendarTimezone struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-timezone"` + Timezone string `xml:",chardata"` +} + // https://tools.ietf.org/html/rfc4791#section-5.2.4 type supportedCalendarData struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-data"` @@ -230,8 +248,11 @@ func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { } type mkcolReq struct { - XMLName xml.Name `xml:"DAV: mkcol"` - ResourceType internal.ResourceType `xml:"set>prop>resourcetype"` - DisplayName string `xml:"set>prop>displayname"` - // TODO this could theoretically contain all addressbook properties? + XMLName xml.Name `xml:"DAV: mkcol"` + ResourceType internal.ResourceType `xml:"set>prop>resourcetype"` + DisplayName string `xml:"set>prop>displayname"` + Description string `xml:"set>prop>calendar-description"` + CalendarColor string `xml:"set>prop>calendar-color"` + CalemdarTimeZone string `xml:"set>prop>calendar-timezone"` + SupportedCalendarComponentSet supportedCalendarComponentSet `xml:"set>prop>supported-calendar-component-set"` } diff --git a/caldav/server.go b/caldav/server.go index d3e83b8..d92eb37 100644 --- a/caldav/server.go +++ b/caldav/server.go @@ -568,6 +568,20 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF return &calendarDescription{Description: cal.Description}, nil } } + if cal.Color != "" { + props[calendarColorName] = func(*internal.RawXMLValue) (interface{}, error) { + return &calendarColor{ + Color: cal.Color, + }, nil + } + } + if cal.Timezone != "" { + props[calendarTimezoneName] = func(*internal.RawXMLValue) (interface{}, error) { + return &calendarTimezone{ + Timezone: cal.Timezone, + }, nil + } + } if cal.MaxResourceSize > 0 { props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) { return &maxResourceSize{Size: cal.MaxResourceSize}, nil @@ -737,7 +751,14 @@ func (b *backend) Mkcol(r *http.Request) error { return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type") } cal.Name = m.DisplayName - // TODO ... + cal.Description = m.Description + cal.Color = strings.TrimSpace(m.CalendarColor) + cal.Timezone = strings.TrimSpace(m.CalemdarTimeZone) + + cal.SupportedComponentSet = make([]string, len(m.SupportedCalendarComponentSet.Comp)) + for i, v := range m.SupportedCalendarComponentSet.Comp { + cal.SupportedComponentSet[i] = v.Name + } } return b.Backend.CreateCalendar(r.Context(), &cal) diff --git a/caldav/server_test.go b/caldav/server_test.go index 594b87b..6fa0751 100644 --- a/caldav/server_test.go +++ b/caldav/server_test.go @@ -2,7 +2,10 @@ package caldav import ( "context" + "encoding/xml" "fmt" + "github.com/emersion/go-webdav/internal" + "github.com/stretchr/testify/assert" "io" "io/ioutil" "net/http/httptest" @@ -22,9 +25,9 @@ var propFindSupportedCalendarComponentRequest = ` ` var testPropFindSupportedCalendarComponentCases = map[*Calendar][]string{ - &Calendar{Path: "/user/calendars/cal"}: []string{"VEVENT"}, - &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VTODO"}}: []string{"VTODO"}, - &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VEVENT", "VTODO"}}: []string{"VEVENT", "VTODO"}, + &Calendar{Path: "/user/calendars/cal"}: {"VEVENT"}, + &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VTODO"}}: {"VTODO"}, + &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VEVENT", "VTODO"}}: {"VEVENT", "VTODO"}, } func TestPropFindSupportedCalendarComponent(t *testing.T) { @@ -33,7 +36,7 @@ func TestPropFindSupportedCalendarComponent(t *testing.T) { req.Body = io.NopCloser(strings.NewReader(propFindSupportedCalendarComponentRequest)) req.Header.Set("Content-Type", "application/xml") w := httptest.NewRecorder() - handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}} + handler := Handler{Backend: &testBackend{calendars: []Calendar{*calendar}}} handler.ServeHTTP(w, req) res := w.Result() @@ -52,6 +55,74 @@ func TestPropFindSupportedCalendarComponent(t *testing.T) { } } +var propFindCalendarRequest = ` + + + + + + + + +` + +func TestPropFindCalendar(t *testing.T) { + calendar := Calendar{ + Path: "/user/calendars/cal", + Name: "Test Calendar", + Description: "This is a test calendar", + Timezone: "BEGIN:VCALENDARfoo", + Color: "#DEADBEEF", + } + + req := httptest.NewRequest("PROPFIND", calendar.Path, nil) + req.Body = io.NopCloser(strings.NewReader(propFindCalendarRequest)) + req.Header.Set("Content-Type", "application/xml") + w := httptest.NewRecorder() + handler := Handler{Backend: &testBackend{calendars: []Calendar{calendar}}} + handler.ServeHTTP(w, req) + + resp := w.Result() + + var ms internal.MultiStatus + err := xml.NewDecoder(resp.Body).Decode(&ms) + assert.NoError(t, err) + assert.Len(t, ms.Responses, 1) + assert.Len(t, ms.Responses[0].PropStats, 1) + assert.Equal(t, 200, ms.Responses[0].PropStats[0].Status.Code) + assert.Len(t, ms.Responses[0].PropStats[0].Prop.Raw, 4) + + rawDisplayName := ms.Responses[0].PropStats[0].Prop.Get(internal.DisplayNameName) + rawCalendarDescription := ms.Responses[0].PropStats[0].Prop.Get(calendarDescriptionName) + rawTimezone := ms.Responses[0].PropStats[0].Prop.Get(calendarTimezoneName) + rawColor := ms.Responses[0].PropStats[0].Prop.Get(calendarColorName) + assert.NotNil(t, rawDisplayName) + assert.NotNil(t, rawCalendarDescription) + assert.NotNil(t, rawTimezone) + assert.NotNil(t, rawColor) + + v0 := internal.DisplayName{} + err = rawDisplayName.Decode(&v0) + assert.NoError(t, err) + assert.Equal(t, calendar.Name, v0.Name) + + v1 := calendarDescription{} + err = rawCalendarDescription.Decode(&v1) + assert.NoError(t, err) + assert.Equal(t, calendar.Description, v1.Description) + + v2 := calendarTimezone{} + err = rawTimezone.Decode(&v2) + assert.NoError(t, err) + assert.Equal(t, calendar.Timezone, v2.Timezone) + + v3 := calendarColor{} + err = rawColor.Decode(&v3) + assert.NoError(t, err) + assert.Equal(t, calendar.Color, v3.Color) +} + var propFindUserPrincipal = ` @@ -68,7 +139,7 @@ func TestPropFindRoot(t *testing.T) { req.Header.Set("Content-Type", "application/xml") w := httptest.NewRecorder() calendar := &Calendar{} - handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}} + handler := Handler{Backend: &testBackend{calendars: []Calendar{*calendar}}} handler.ServeHTTP(w, req) res := w.Result() @@ -118,7 +189,7 @@ func TestMultiCalendarBackend(t *testing.T) { req := httptest.NewRequest("PROPFIND", "/user/calendars/", strings.NewReader(propFindUserPrincipal)) req.Header.Set("Content-Type", "application/xml") w := httptest.NewRecorder() - handler := Handler{Backend: testBackend{ + handler := Handler{Backend: &testBackend{ calendars: calendars, objectMap: map[string][]CalendarObject{ calendarB.Path: []CalendarObject{object}, @@ -177,41 +248,245 @@ func TestMultiCalendarBackend(t *testing.T) { } } +var mkcolRequestData = ` + + + + + + + + + Test calendar + A calendar for testing + #009688FF + + + + + + + + + + + +` + +func TestCreateCalendar(t *testing.T) { + tb := testBackend{ + calendars: nil, + objectMap: nil, + } + b := backend{ + Backend: &tb, + Prefix: "/dav", + } + req := httptest.NewRequest("MKCOL", "/dav/calendars/user0/test-calendar", strings.NewReader(mkcolRequestData)) + req.Header.Set("Content-Type", "application/xml") + + err := b.Mkcol(req) + assert.NoError(t, err) + assert.Len(t, tb.calendars, 1) + c := tb.calendars[0] + assert.Equal(t, "Test calendar", c.Name) + assert.Equal(t, "/dav/calendars/user0/test-calendar", c.Path) + assert.Equal(t, "A calendar for testing", c.Description) + assert.Equal(t, "#009688FF", c.Color) + assert.Equal(t, []string{"VEVENT", "VTODO", "VJOURNAL"}, c.SupportedComponentSet) + assert.Contains(t, c.Timezone, "BEGIN:VCALENDAR") +} + +var mkcolRequestDataMinimalBody = ` + + + + + + + + + Test calendar + + +` + +func TestCreateCalendarMinimalBody(t *testing.T) { + tb := testBackend{ + calendars: nil, + objectMap: nil, + } + b := backend{ + Backend: &tb, + Prefix: "/dav", + } + req := httptest.NewRequest("MKCOL", "/dav/calendars/user0/test-calendar", strings.NewReader(mkcolRequestDataMinimalBody)) + req.Header.Set("Content-Type", "application/xml") + + err := b.Mkcol(req) + assert.NoError(t, err) + assert.Len(t, tb.calendars, 1) + c := tb.calendars[0] + assert.Equal(t, "Test calendar", c.Name) + assert.Equal(t, "/dav/calendars/user0/test-calendar", c.Path) + assert.Equal(t, "", c.Description) + assert.Equal(t, "", c.Color) + assert.Equal(t, []string{}, c.SupportedComponentSet) + assert.Equal(t, "", c.Timezone) +} + type testBackend struct { calendars []Calendar objectMap map[string][]CalendarObject } -func (t testBackend) CreateCalendar(ctx context.Context, calendar *Calendar) error { +func (t *testBackend) CreateCalendar(ctx context.Context, calendar *Calendar) error { + t.calendars = append(t.calendars, *calendar) return nil } -func (t testBackend) ListCalendars(ctx context.Context) ([]Calendar, error) { +func (t *testBackend) ListCalendars(ctx context.Context) ([]Calendar, error) { return t.calendars, nil } -func (t testBackend) GetCalendar(ctx context.Context, path string) (*Calendar, error) { +func (t *testBackend) GetCalendar(ctx context.Context, path string) (*Calendar, error) { for _, cal := range t.calendars { if cal.Path == path { return &cal, nil } } - return nil, fmt.Errorf("Calendar for path: %s not found", path) + return nil, fmt.Errorf("calendar for path: %s not found", path) } -func (t testBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { +func (t *testBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { return "/user/calendars/", nil } -func (t testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { +func (t *testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { return "/user/", nil } -func (t testBackend) DeleteCalendarObject(ctx context.Context, path string) error { +func (t *testBackend) DeleteCalendarObject(ctx context.Context, path string) error { return nil } -func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) { +func (t *testBackend) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) { for _, objs := range t.objectMap { for _, obj := range objs { if obj.Path == path { @@ -219,17 +494,17 @@ func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *Ca } } } - return nil, fmt.Errorf("Couldn't find calendar object at: %s", path) + return nil, fmt.Errorf("couldn't find calendar object at: %s", path) } -func (t testBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) { +func (t *testBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) { return nil, nil } -func (t testBackend) ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) { +func (t *testBackend) ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) { return t.objectMap[path], nil } -func (t testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) { +func (t *testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) { return nil, nil } diff --git a/go.mod b/go.mod index 7c5445a..78d380d 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.13 require ( github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 + github.com/stretchr/testify v1.8.4 // indirect github.com/teambition/rrule-go v1.8.2 // indirect ) diff --git a/go.sum b/go.sum index 472fda6..777af55 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,23 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ= github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=