diff --git a/caldav/caldav.go b/caldav/caldav.go index 04b8399..0f4bea7 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -145,3 +145,8 @@ type SyncResponse struct { Updated []CalendarObject Deleted []string } + +type Inbox struct { + Path string + UserAddressSet []string +} diff --git a/caldav/elements.go b/caldav/elements.go index aed6050..f4ac7cd 100644 --- a/caldav/elements.go +++ b/caldav/elements.go @@ -11,7 +11,9 @@ import ( const namespace = "urn:ietf:params:xml:ns:caldav" var ( - calendarHomeSetName = xml.Name{namespace, "calendar-home-set"} + calendarHomeSetName = xml.Name{namespace, "calendar-home-set"} + calendarUserAddressSetName = xml.Name{namespace, "calendar-user-address-set"} + scheduleInboxURLName = xml.Name{namespace, "schedule-inbox-URL"} calendarDescriptionName = xml.Name{namespace, "calendar-description"} supportedCalendarDataName = xml.Name{namespace, "supported-calendar-data"} @@ -21,8 +23,9 @@ 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"} + scheduleInboxName = xml.Name{namespace, "schedule-inbox"} + calendarDataName = xml.Name{namespace, "calendar-data"} ) // https://tools.ietf.org/html/rfc4791#section-6.2.1 @@ -35,6 +38,18 @@ func (a *calendarHomeSet) GetXMLName() xml.Name { return calendarHomeSetName } +// https://datatracker.ietf.org/doc/html/rfc6638#section-2.4.1 +type calendarUserAddressSet struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-user-address-set"` + Addresses []string `xml:"DAV: href"` +} + +// https://datatracker.ietf.org/doc/html/rfc6638#section-2.2.1 +type scheduleInboxURL struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav schedule-inbox-URL"` + Href internal.Href `xml:"DAV: href"` +} + // https://tools.ietf.org/html/rfc4791#section-5.2.1 type calendarDescription struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-description"` diff --git a/caldav/server.go b/caldav/server.go index 1a25479..7ddb734 100644 --- a/caldav/server.go +++ b/caldav/server.go @@ -44,6 +44,32 @@ type Backend interface { webdav.UserPrincipalBackend } +// Conforming to `InboxBackend` signals RFC6638 support, which is partial and focused on replying to invitations. +// https://datatracker.ietf.org/doc/html/rfc6638 +// +// In practice this does a few things to support scheduling. +// 1. It adds the `calendar-auto-schedule` capability +// 2. It returns `schedule-inbox-URL` and `calendar-user-address-set` based on the `GetInbox` result. +// 3. It adds an inbox-calendar to the `PROPFIND` for all collections. +// 4. It returns the `CalendarObject`:s using `ListCalendarObjects` when getting a `REPORT` towards the inbox. +// +// To properly implement this, you need to. +// 1. Implement `GetInbox`, and return a list of addresses for the users that can be invited, e.g. on the form: `mailto:test@example.com`. +// 2. Implement `ListCalendarObjects`, which returns `CalendarObject`:s that have an attendee that matches one of the addresses returned from `GetInbox`. e.g. +// ```go +// attendee := ical.NewProp(ical.PropAttendee) +// attendee.Params.Set(ical.ParamCommonName, name) +// attendee.Params.Set(ical.ParamParticipationStatus, "NEEDS-ACTION") +// attendee.SetText("mailto:test@example.com") +// attendee.SetValueType(ical.ValueCalendarAddress) +// event.Props.Add(attendee) +// ``` +// +// Doing this should make it possible to accept/decline invitations in your client, which would trigger a `PutCalendarObject` towards the invite, with an updateded `ParamParticipationStatus`. +type InboxBackend interface { + GetInbox(ctx context.Context) (*Inbox, error) +} + // Handler handles CalDAV HTTP requests. It can be used to create a CalDAV // server. type Handler struct { @@ -300,14 +326,21 @@ const ( resourceTypeCalendarHomeSet resourceTypeCalendar resourceTypeCalendarObject + resourceTypeScheduleInbox ) -func (b *backend) resourceTypeAtPath(reqPath string) resourceType { +func (b *backend) resourceTypeAtPath(ctx context.Context, reqPath string) resourceType { p := path.Clean(reqPath) p = strings.TrimPrefix(p, b.Prefix) if !strings.HasPrefix(p, "/") { p = "/" + p } + if inboxBackend, ok := b.Backend.(InboxBackend); ok { + inbox, err := inboxBackend.GetInbox(ctx) + if err == nil && inbox.Path == p { + return resourceTypeScheduleInbox + } + } if p == "/" { return resourceTypeRoot } @@ -317,7 +350,12 @@ func (b *backend) resourceTypeAtPath(reqPath string) resourceType { func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { caps = []string{"calendar-access"} - if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendarObject { + if _, ok := b.Backend.(InboxBackend); ok { + // https://datatracker.ietf.org/doc/html/rfc6638#section-2 + caps = append(caps, "calendar-auto-schedule") + } + + if b.resourceTypeAtPath(r.Context(), r.URL.Path) != resourceTypeCalendarObject { return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil } @@ -367,7 +405,7 @@ func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error { } func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) { - resType := b.resourceTypeAtPath(r.URL.Path) + resType := b.resourceTypeAtPath(r.Context(), r.URL.Path) var dataReq CalendarCompRequest var resps []internal.Response @@ -436,7 +474,7 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i } resps = append(resps, *resp) if depth != internal.DepthZero { - resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab) + resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab.Path) if err != nil { return nil, err } @@ -453,6 +491,25 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i return nil, err } resps = append(resps, *resp) + case resourceTypeScheduleInbox: + if inboxBackend, ok := b.Backend.(InboxBackend); ok { + inbox, err := inboxBackend.GetInbox(r.Context()) + if err != nil { + return nil, err + } + resp, err := b.propFindInbox(propfind, inbox.Path) + if err != nil { + return nil, err + } + resps = append(resps, *resp) + if depth != internal.DepthZero { + resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, inbox.Path) + if err != nil { + return nil, err + } + resps = append(resps, resps_...) + } + } } return internal.NewMultiStatus(resps...), nil @@ -492,6 +549,18 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal. }), internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, internal.PrincipalName)), } + if inboxBackend, ok := b.Backend.(InboxBackend); ok { + inbox, err := inboxBackend.GetInbox(ctx) + if err != nil { + return nil, err + } + props[scheduleInboxURLName] = internal.PropFindValue(&scheduleInboxURL{ + Href: internal.Href{Path: inbox.Path}, + }) + props[calendarUserAddressSetName] = internal.PropFindValue(&calendarUserAddressSet{ + Addresses: inbox.UserAddressSet, + }) + } return internal.NewPropFindResponse(principalPath, propfind, props) } @@ -576,6 +645,18 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF return internal.NewPropFindResponse(cal.Path, propfind, props) } +func (b *backend) propFindInbox(propfind *internal.PropFind, path string) (*internal.Response, error) { + props := map[xml.Name]internal.PropFindFunc{ + internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, scheduleInboxName)), + internal.CurrentUserPrivilegeSetName: internal.PropFindValue(&internal.CurrentUserPrivilegeSet{ + Privilege: []internal.Privilege{{ + SchedulerDeliver: &struct{}{}, + }}, + }), + } + return internal.NewPropFindResponse(path, propfind, props) +} + func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) { abs, err := b.Backend.ListCalendars(ctx) if err != nil { @@ -590,13 +671,24 @@ func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.P } resps = append(resps, *resp) if recurse { - resps_, err := b.propFindAllCalendarObjects(ctx, propfind, &ab) + resps_, err := b.propFindAllCalendarObjects(ctx, propfind, ab.Path) if err != nil { return nil, err } resps = append(resps, resps_...) } } + if inboxBackend, ok := b.Backend.(InboxBackend); ok { + inbox, err := inboxBackend.GetInbox(ctx) + if err != nil { + return nil, err + } + resp, err := b.propFindInbox(propfind, inbox.Path) + if err != nil { + return nil, err + } + resps = append(resps, *resp) + } return resps, nil } @@ -643,9 +735,9 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal return internal.NewPropFindResponse(co.Path, propfind, props) } -func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, cal *Calendar) ([]internal.Response, error) { +func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, path string) ([]internal.Response, error) { var dataReq CalendarCompRequest - aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq) + aos, err := b.Backend.ListCalendarObjects(ctx, path, &dataReq) if err != nil { return nil, err } @@ -716,7 +808,7 @@ func (b *backend) Delete(r *http.Request) error { } func (b *backend) Mkcol(r *http.Request) error { - if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar { + if b.resourceTypeAtPath(r.Context(), r.URL.Path) != resourceTypeCalendar { return internal.HTTPErrorf(http.StatusForbidden, "caldav: calendar creation not allowed at given location") } diff --git a/caldav/server_test.go b/caldav/server_test.go index 594b87b..812cf47 100644 --- a/caldav/server_test.go +++ b/caldav/server_test.go @@ -177,6 +177,88 @@ func TestMultiCalendarBackend(t *testing.T) { } } +func TestCalendarWithInbox(t *testing.T) { + inboxPath := "/user/inbox" + summary := "Team Meeting Invite" + calendarInvite := calendarObjectWithInvite(inboxPath, summary) + handler := Handler{Backend: inboxTestBackend{ + testBackend: testBackend{ + calendars: []Calendar{{Path: "/user/calendars/a"}}, + objectMap: map[string][]CalendarObject{ + inboxPath: {calendarInvite}, + }, + }, + inbox: Inbox{Path: inboxPath, UserAddressSet: []string{"mailto:test@example.com"}}, + }} + + // The OPTIONS request should signal scheduling support + req := httptest.NewRequest("OPTIONS", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + dav := res.Header.Get("DAV") + if !strings.Contains(dav, "calendar-auto-schedule") { + t.Errorf("Expected DAV header to contain calendar-auto-schedule, got: %s", dav) + } + + // PROPFIND should reveal where the inbox is located + req = httptest.NewRequest("PROPFIND", "/user/", strings.NewReader(propFindSchedulingProps)) + req.Header.Set("Content-Type", "application/xml") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + res = w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + resp := string(data) + if !strings.Contains(resp, `/user/inbox`) { + t.Errorf("Expected schedule-inbox-URL in principal PROPFIND, response:\n%s", resp) + } + if !strings.Contains(resp, `mailto:test@example.com`) { + t.Errorf("Expected calendar-user-address-set in principal PROPFIND, response:\n%s", resp) + } + + // PROPFIND on the inbox should return the privileges + req = httptest.NewRequest("PROPFIND", inboxPath, strings.NewReader(propFindSchedulingProps)) + req.Header.Set("Content-Type", "application/xml") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + res = w.Result() + defer res.Body.Close() + data, err = io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + resp = string(data) + + if !strings.Contains(resp, ``) { + t.Errorf("Expected schedule-deliver privilege in response:\n%s", resp) + } + + // Now do a REPORT to get the actual data for the event + req = httptest.NewRequest("REPORT", inboxPath, strings.NewReader(fmt.Sprintf(reportCalendarData, calendarInvite.Path))) + req.Header.Set("Content-Type", "application/xml") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + res = w.Result() + defer res.Body.Close() + data, err = io.ReadAll(res.Body) + if err != nil { + t.Error(err) + } + resp = string(data) + if !strings.Contains(resp, fmt.Sprintf("SUMMARY:%s", summary)) { + t.Errorf("ICAL content not properly returned in response:\n%v", resp) + } +} + type testBackend struct { calendars []Calendar objectMap map[string][]CalendarObject @@ -233,3 +315,40 @@ func (t testBackend) ListCalendarObjects(ctx context.Context, path string, req * func (t testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) { return nil, nil } + +// inboxTestBackend extends testBackend with InboxBackend support (RFC 6638 scheduling) +type inboxTestBackend struct { + testBackend + inbox Inbox +} + +func (t inboxTestBackend) GetInbox(ctx context.Context) (*Inbox, error) { + return &t.inbox, nil +} + +var propFindSchedulingProps = ` + + + + + + + + + +` + +func calendarObjectWithInvite(inboxPath string, summary string) CalendarObject { + event := ical.NewEvent() + event.Props.SetText(ical.PropUID, "invite-uid-1") + event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now()) + event.Props.SetText(ical.PropSummary, summary) + cal := ical.NewCalendar() + cal.Props.SetText(ical.PropVersion, "2.0") + cal.Props.SetText(ical.PropProductID, "-//Test//Test//EN") + cal.Children = []*ical.Component{event.Component} + return CalendarObject{ + Path: inboxPath + "/invite1.ics", + Data: cal, + } +} diff --git a/internal/elements.go b/internal/elements.go index 77df514..a90c026 100644 --- a/internal/elements.go +++ b/internal/elements.go @@ -466,8 +466,10 @@ type CurrentUserPrivilegeSet struct { } // https://tools.ietf.org/html/rfc3744#section-5.4 +// https://datatracker.ietf.org/doc/html/rfc6638#section-6.1.1 type Privilege struct { - XMLName xml.Name `xml:"DAV: privilege"` - Read *struct{} `xml:"DAV: read,omitempty"` - Write *struct{} `xml:"DAV: write,omitempty"` + XMLName xml.Name `xml:"DAV: privilege"` + Read *struct{} `xml:"DAV: read,omitempty"` + Write *struct{} `xml:"DAV: write,omitempty"` + SchedulerDeliver *struct{} `xml:"DAV: schedule-deliver,omitempty"` }