Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions caldav/caldav.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,8 @@ type SyncResponse struct {
Updated []CalendarObject
Deleted []string
}

type Inbox struct {
Path string
UserAddressSet []string
}
21 changes: 18 additions & 3 deletions caldav/elements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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
Expand All @@ -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"`
Expand Down
108 changes: 100 additions & 8 deletions caldav/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}

Expand Down
119 changes: 119 additions & 0 deletions caldav/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<schedule-inbox-URL xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">/user/inbox</href></schedule-inbox-URL>`) {
t.Errorf("Expected schedule-inbox-URL in principal PROPFIND, response:\n%s", resp)
}
if !strings.Contains(resp, `<calendar-user-address-set xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">mailto:test@example.com</href></calendar-user-address-set>`) {
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, `<current-user-privilege-set xmlns="DAV:"><privilege xmlns="DAV:"><schedule-deliver xmlns="DAV:"></schedule-deliver></privilege></current-user-privilege-set>`) {
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
Expand Down Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<A:propfind xmlns:A="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<A:prop>
<A:current-user-principal/>
<A:resourcetype/>
<C:schedule-inbox-URL/>
<C:calendar-user-address-set/>
<A:current-user-privilege-set/>
</A:prop>
</A:propfind>
`

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,
}
}
8 changes: 5 additions & 3 deletions internal/elements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}