Support custom user principal and home set paths

Currently, the user principal path and the home set path are both
hardcoded to "/", for both CalDAV and CardDAV. This poses a challenge if
one wishes to run a CardDAV and CalDAV server in the same server.

This commit introduces the concept of a UserPrincipalBackend. This
backend must provide the path of the current user's principal URL from
the given request context.

The CalDAV and CardDAV backends are extended to also function as
UserPrincipalBackend. In addition, they are required to supply the path
of the respective home set (`calendar-home-set` and
`addressbook-home-set`). The CardDAV and CalDAV servers act accordingly.

The individual servers will continue to work as before (including the
option of keeping everything at "/"). If one wishes to run CardDAV and
CalDAV in parallel, the new `webdav.ServeUserPrincipal()` can be used as
a convenience function to serve a common user principal URL for both
servers. The input for this function can be easily computed by the
application by getting the home set paths from the backends and using
`caldav.NewCalendarHomeSet()` and `carddav.NewAddressbookHomeSet()` to
create the home sets.

Note that the storage backend will have to know about these paths as
well. For any non-trivial use case, a storage backend should probably
have access to the same UserPrincipalBackend. That is, however, an
implementation detail and doesn't have to be reflected in the
interfaces.
This commit is contained in:
Conrad Hoffmann 2022-03-21 09:16:50 +01:00 committed by Simon Ser
parent b5c6f8927c
commit 6887b6b812
8 changed files with 268 additions and 40 deletions

View File

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

View File

@ -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"`

View File

@ -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 != "" {

View File

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

View File

@ -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"`

View File

@ -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
},

32
elements.go Normal file
View File

@ -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"`
}

View File

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