diff --git a/caldav/caldav.go b/caldav/caldav.go index 6bb01e9..60d84f4 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -7,8 +7,14 @@ import ( "time" "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/internal" ) +func NewCalendarHomeSet(path string) webdav.BackendSuppliedHomeSet { + return &calendarHomeSet{Href: internal.Href{Path: path}} +} + type Calendar struct { Path string Name string diff --git a/caldav/elements.go b/caldav/elements.go index 7b00fd5..255ab7a 100644 --- a/caldav/elements.go +++ b/caldav/elements.go @@ -31,6 +31,10 @@ type calendarHomeSet struct { Href internal.Href `xml:"DAV: href"` } +func (a *calendarHomeSet) GetXMLName() xml.Name { + return calendarHomeSetName +} + // 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 ee92d66..c3d72cc 100644 --- a/caldav/server.go +++ b/caldav/server.go @@ -8,7 +8,7 @@ import ( "time" "github.com/emersion/go-ical" - + "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/internal" ) @@ -16,10 +16,13 @@ import ( // Backend is a CalDAV server backend. type Backend interface { + CalendarHomeSetPath(ctx context.Context) (string, error) Calendar(ctx context.Context) (*Calendar, error) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) ListCalendarObjects(ctx context.Context, req *CalendarCompRequest) ([]CalendarObject, error) QueryCalendarObjects(ctx context.Context, query *CalendarQuery) ([]CalendarObject, error) + + webdav.UserPrincipalBackend } // Handler handles CalDAV HTTP requests. It can be used to create a CalDAV @@ -35,12 +38,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if r.URL.Path == "/.well-known/caldav" { - http.Redirect(w, r, "/", http.StatusMovedPermanently) + principalPath, err := h.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + http.Error(w, "caldav: failed to determine current user principal", http.StatusInternalServerError) + return + } + + if r.URL.Path == "/.well-known/caldav" { + http.Redirect(w, r, principalPath, http.StatusMovedPermanently) return } - var err error switch r.Method { case "REPORT": err = h.handleReport(w, r) @@ -182,7 +190,17 @@ type backend struct { func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { caps = []string{"calendar-access"} - if r.URL.Path == "/" { + homeSetPath, err := b.Backend.CalendarHomeSetPath(r.Context()) + if err != nil { + return nil, nil, err + } + + principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + return nil, nil, err + } + + if r.URL.Path == "/" || r.URL.Path == principalPath || r.URL.Path == homeSetPath { return caps, []string{http.MethodOptions, "PROPFIND", "REPORT"}, nil } @@ -209,14 +227,30 @@ 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) { + homeSetPath, err := b.Backend.CalendarHomeSetPath(r.Context()) + if err != nil { + return nil, err + } + principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + return nil, err + } + var resps []internal.Response - if r.URL.Path == "/" { + + if r.URL.Path == principalPath { + resp, err := b.propfindUserPrincipal(r.Context(), propfind, homeSetPath) + if err != nil { + return nil, err + } + resps = append(resps, *resp) + } else if r.URL.Path == homeSetPath { cal, err := b.Backend.Calendar(r.Context()) if err != nil { return nil, err } - resp, err := b.propfindCalendar(propfind, cal) + resp, err := b.propfindCalendar(r.Context(), propfind, cal) if err != nil { return nil, err } @@ -225,13 +259,38 @@ func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth i if depth != internal.DepthZero { // TODO } + } else { + // TODO } return internal.NewMultistatus(resps...), nil } -func (b *backend) propfindCalendar(propfind *internal.Propfind, cal *Calendar) (*internal.Response, error) { +func (b *backend) propfindUserPrincipal(ctx context.Context, propfind *internal.Propfind, homeSetPath string) (*internal.Response, error) { + principalPath, err := b.Backend.CurrentUserPrincipal(ctx) + if err != nil { + return nil, err + } props := map[xml.Name]internal.PropfindFunc{ + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil + }, + calendarHomeSetName: func(*internal.RawXMLValue) (interface{}, error) { + return &calendarHomeSet{Href: internal.Href{Path: homeSetPath}}, nil + }, + } + return internal.NewPropfindResponse(principalPath, propfind, props) +} + +func (b *backend) propfindCalendar(ctx context.Context, propfind *internal.Propfind, cal *Calendar) (*internal.Response, error) { + principalPath, err := b.Backend.CurrentUserPrincipal(ctx) + if err != nil { + return nil, err + } + props := map[xml.Name]internal.PropfindFunc{ + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil + }, internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) { return internal.NewResourceType(internal.CollectionName, calendarName), nil }, @@ -252,14 +311,6 @@ func (b *backend) propfindCalendar(propfind *internal.Propfind, cal *Calendar) ( }, }, nil }, - // TODO: this is a principal property - calendarHomeSetName: func(*internal.RawXMLValue) (interface{}, error) { - return &calendarHomeSet{Href: internal.Href{Path: "/"}}, nil - }, - // TODO: this should be set on all resources - internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { - return &internal.CurrentUserPrincipal{Href: internal.Href{Path: "/"}}, nil - }, } if cal.Description != "" { diff --git a/carddav/carddav.go b/carddav/carddav.go index dec57bc..68d3244 100644 --- a/carddav/carddav.go +++ b/carddav/carddav.go @@ -7,8 +7,14 @@ import ( "time" "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/internal" ) +func NewAddressBookHomeSet(path string) webdav.BackendSuppliedHomeSet { + return &addressbookHomeSet{Href: internal.Href{Path: path}} +} + type AddressDataType struct { ContentType string Version string diff --git a/carddav/elements.go b/carddav/elements.go index fee3f16..8b18714 100644 --- a/carddav/elements.go +++ b/carddav/elements.go @@ -29,6 +29,10 @@ type addressbookHomeSet struct { Href internal.Href `xml:"DAV: href"` } +func (a *addressbookHomeSet) GetXMLName() xml.Name { + return addressBookHomeSetName +} + type addressbookDescription struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-description"` Description string `xml:",chardata"` diff --git a/carddav/server.go b/carddav/server.go index d695e90..4ba4621 100644 --- a/carddav/server.go +++ b/carddav/server.go @@ -9,6 +9,7 @@ import ( "net/http" "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/internal" ) @@ -25,12 +26,15 @@ type PutAddressObjectOptions struct { // Backend is a CardDAV server backend. type Backend interface { + AddressbookHomeSetPath(ctx context.Context) (string, error) AddressBook(ctx context.Context) (*AddressBook, error) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error) ListAddressObjects(ctx context.Context, req *AddressDataRequest) ([]AddressObject, error) QueryAddressObjects(ctx context.Context, query *AddressBookQuery) ([]AddressObject, error) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (loc string, err error) DeleteAddressObject(ctx context.Context, path string) error + + webdav.UserPrincipalBackend } // Handler handles CardDAV HTTP requests. It can be used to create a CardDAV @@ -46,12 +50,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if r.URL.Path == "/.well-known/carddav" { - http.Redirect(w, r, "/", http.StatusMovedPermanently) + principalPath, err := h.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + http.Error(w, "carddav: failed to determine current user principal", http.StatusInternalServerError) + return + } + + if r.URL.Path == "/.well-known/carddav" { + http.Redirect(w, r, principalPath, http.StatusMovedPermanently) return } - var err error switch r.Method { case "REPORT": err = h.handleReport(w, r) @@ -176,7 +185,7 @@ func (h *Handler) handleQuery(ctx context.Context, w http.ResponseWriter, query AllProp: query.AllProp, PropName: query.PropName, } - resp, err := b.propfindAddressObject(&propfind, &ao) + resp, err := b.propfindAddressObject(ctx, &propfind, &ao) if err != nil { return err } @@ -216,7 +225,7 @@ func (h *Handler) handleMultiget(ctx context.Context, w http.ResponseWriter, mul AllProp: multiget.AllProp, PropName: multiget.PropName, } - resp, err := b.propfindAddressObject(&propfind, ao) + resp, err := b.propfindAddressObject(ctx, &propfind, ao) if err != nil { return err } @@ -234,7 +243,17 @@ type backend struct { func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { caps = []string{"addressbook"} - if r.URL.Path == "/" { + homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context()) + if err != nil { + return nil, nil, err + } + + principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + return nil, nil, err + } + + if r.URL.Path == "/" || r.URL.Path == principalPath || r.URL.Path == homeSetPath { // Note: some clients assume the address book is read-only when // DELETE/MKCOL are missing return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil @@ -259,10 +278,6 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e } func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error { - if r.URL.Path == "/" { - return &internal.HTTPError{Code: http.StatusMethodNotAllowed} - } - var dataReq AddressDataRequest if r.Method != http.MethodHead { dataReq.AllProp = true @@ -287,16 +302,32 @@ 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) { + homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context()) + if err != nil { + return nil, err + } + principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) + if err != nil { + return nil, err + } + var dataReq AddressDataRequest var resps []internal.Response - if r.URL.Path == "/" { + + if r.URL.Path == principalPath { + resp, err := b.propfindUserPrincipal(r.Context(), propfind, homeSetPath) + if err != nil { + return nil, err + } + resps = append(resps, *resp) + } else if r.URL.Path == homeSetPath { ab, err := b.Backend.AddressBook(r.Context()) if err != nil { return nil, err } - resp, err := b.propfindAddressBook(propfind, ab) + resp, err := b.propfindAddressBook(r.Context(), propfind, ab) if err != nil { return nil, err } @@ -309,7 +340,7 @@ func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth i } for _, ao := range aos { - resp, err := b.propfindAddressObject(propfind, &ao) + resp, err := b.propfindAddressObject(r.Context(), propfind, &ao) if err != nil { return nil, err } @@ -322,7 +353,7 @@ func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth i return nil, err } - resp, err := b.propfindAddressObject(propfind, ao) + resp, err := b.propfindAddressObject(r.Context(), propfind, ao) if err != nil { return nil, err } @@ -332,8 +363,31 @@ func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth i return internal.NewMultistatus(resps...), nil } -func (b *backend) propfindAddressBook(propfind *internal.Propfind, ab *AddressBook) (*internal.Response, error) { +func (b *backend) propfindUserPrincipal(ctx context.Context, propfind *internal.Propfind, homeSetPath string) (*internal.Response, error) { + principalPath, err := b.Backend.CurrentUserPrincipal(ctx) + if err != nil { + return nil, err + } props := map[xml.Name]internal.PropfindFunc{ + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil + }, + addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) { + return &addressbookHomeSet{Href: internal.Href{Path: homeSetPath}}, nil + }, + } + return internal.NewPropfindResponse(principalPath, propfind, props) +} + +func (b *backend) propfindAddressBook(ctx context.Context, propfind *internal.Propfind, ab *AddressBook) (*internal.Response, error) { + principalPath, err := b.Backend.CurrentUserPrincipal(ctx) + if err != nil { + return nil, err + } + props := map[xml.Name]internal.PropfindFunc{ + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil + }, internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) { return internal.NewResourceType(internal.CollectionName, addressBookName), nil }, @@ -351,14 +405,6 @@ func (b *backend) propfindAddressBook(propfind *internal.Propfind, ab *AddressBo }, }, nil }, - // TODO: this is a principal property - addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) { - return &addressbookHomeSet{Href: internal.Href{Path: "/"}}, nil - }, - // TODO: this should be set on all resources - internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { - return &internal.CurrentUserPrincipal{Href: internal.Href{Path: "/"}}, nil - }, } if ab.MaxResourceSize > 0 { @@ -370,8 +416,15 @@ func (b *backend) propfindAddressBook(propfind *internal.Propfind, ab *AddressBo return internal.NewPropfindResponse(ab.Path, propfind, props) } -func (b *backend) propfindAddressObject(propfind *internal.Propfind, ao *AddressObject) (*internal.Response, error) { +func (b *backend) propfindAddressObject(ctx context.Context, propfind *internal.Propfind, ao *AddressObject) (*internal.Response, error) { + principalPath, err := b.Backend.CurrentUserPrincipal(ctx) + if err != nil { + return nil, err + } props := map[xml.Name]internal.PropfindFunc{ + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil + }, internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) { return &internal.GetContentType{Type: vcard.MIMEType}, nil }, diff --git a/elements.go b/elements.go new file mode 100644 index 0000000..70f9e9b --- /dev/null +++ b/elements.go @@ -0,0 +1,32 @@ +package webdav + +import ( + "encoding/xml" + + "github.com/emersion/go-webdav/internal" +) + +var ( + principalName = xml.Name{"DAV:", "principal"} + principalAlternateURISetName = xml.Name{"DAV:", "alternate-URI-set"} + principalURLName = xml.Name{"DAV:", "principal-URL"} + groupMembershipName = xml.Name{"DAV:", "group-membership"} +) + +// https://datatracker.ietf.org/doc/html/rfc3744#section-4.1 +type principalAlternateURISet struct { + XMLName xml.Name `xml:"DAV: alternate-URI-set"` + Hrefs []internal.Href `xml:"href"` +} + +// https://datatracker.ietf.org/doc/html/rfc3744#section-4.2 +type principalURL struct { + XMLName xml.Name `xml:"DAV: principal-URL"` + Href internal.Href `xml:"href"` +} + +// https://datatracker.ietf.org/doc/html/rfc3744#section-4.4 +type groupMembership struct { + XMLName xml.Name `xml:"DAV: group-membership"` + Hrefs []internal.Href `xml:"href"` +} diff --git a/server.go b/server.go index bc11acd..004e600 100644 --- a/server.go +++ b/server.go @@ -1,11 +1,13 @@ package webdav import ( + "context" "encoding/xml" "io" "net/http" "os" "strconv" + "strings" "github.com/emersion/go-webdav/internal" ) @@ -243,3 +245,73 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr } return created, err } + +// BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a +// CardDAV addressbook-home-set. It should only be created via +// `caldav.NewCalendarHomeSet()` or `carddav.NewAddressbookHomeSet()`. Only to +// be used server-side, for listing a user's home sets as determined by the +// (external) backend. +type BackendSuppliedHomeSet interface { + GetXMLName() xml.Name +} + +// UserPrincipalBackend can determine the current user's principal URL for a +// given request context. +type UserPrincipalBackend interface { + CurrentUserPrincipal(ctx context.Context) (string, error) +} + +type ServeUserPrincipalOptions struct { + UserPrincipalPath string + HomeSets []BackendSuppliedHomeSet +} + +// ServeUserPrincipal replies to requests for the user principal URL +func ServeUserPrincipal(w http.ResponseWriter, r *http.Request, options *ServeUserPrincipalOptions) { + switch r.Method { + case http.MethodOptions: + caps := []string{"1", "3"} + allow := []string{http.MethodOptions, "PROPFIND"} + w.Header().Add("DAV", strings.Join(caps, ", ")) + w.Header().Add("Allow", strings.Join(allow, ", ")) + w.WriteHeader(http.StatusNoContent) + case "PROPFIND": + if err := serveUserPrincipalPropfind(w, r, options); err != nil { + internal.ServeError(w, err) + } + default: + http.Error(w, "unsupported method", http.StatusMethodNotAllowed) + } +} + +func serveUserPrincipalPropfind(w http.ResponseWriter, r *http.Request, options *ServeUserPrincipalOptions) error { + var propfind internal.Propfind + if err := internal.DecodeXMLRequest(r, &propfind); err != nil { + return err + } + props := map[xml.Name]internal.PropfindFunc{ + internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) { + return internal.NewResourceType(principalName), nil + }, + internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { + return &internal.CurrentUserPrincipal{Href: internal.Href{Path: options.UserPrincipalPath}}, nil + }, + } + + // TODO: handle Depth and more properties + + for _, homeSet := range options.HomeSets { + hs := homeSet // capture variable for closure + props[homeSet.GetXMLName()] = func(*internal.RawXMLValue) (interface{}, error) { + return hs, nil + } + } + + resp, err := internal.NewPropfindResponse(r.URL.Path, &propfind, props) + if err != nil { + return err + } + + ms := internal.NewMultistatus(*resp) + return internal.ServeMultistatus(w, ms) +}