mirror of
https://github.com/1f349/go-webdav.git
synced 2024-12-23 00:34:23 +00:00
6887b6b812
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.
534 lines
16 KiB
Go
534 lines
16 KiB
Go
package carddav
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
|
|
"github.com/emersion/go-vcard"
|
|
"github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/internal"
|
|
)
|
|
|
|
// TODO: add support for multiple address books
|
|
|
|
type PutAddressObjectOptions struct {
|
|
// IfNoneMatch indicates that the client does not want to overwrite
|
|
// an existing resource.
|
|
IfNoneMatch bool
|
|
// IfMatch provides the ETag of the resource that the client intends
|
|
// to overwrite, can be ""
|
|
IfMatch string
|
|
}
|
|
|
|
// 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
|
|
// server.
|
|
type Handler struct {
|
|
Backend Backend
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if h.Backend == nil {
|
|
http.Error(w, "carddav: no backend available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
switch r.Method {
|
|
case "REPORT":
|
|
err = h.handleReport(w, r)
|
|
default:
|
|
b := backend{h.Backend}
|
|
hh := internal.Handler{&b}
|
|
hh.ServeHTTP(w, r)
|
|
}
|
|
|
|
if err != nil {
|
|
internal.ServeError(w, err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) error {
|
|
var report reportReq
|
|
if err := internal.DecodeXMLRequest(r, &report); err != nil {
|
|
return err
|
|
}
|
|
|
|
if report.Query != nil {
|
|
return h.handleQuery(r.Context(), w, report.Query)
|
|
} else if report.Multiget != nil {
|
|
return h.handleMultiget(r.Context(), w, report.Multiget)
|
|
}
|
|
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: expected addressbook-query or addressbook-multiget element in REPORT request")
|
|
}
|
|
|
|
func decodePropFilter(el *propFilter) (*PropFilter, error) {
|
|
pf := &PropFilter{Name: el.Name, Test: FilterTest(el.Test)}
|
|
if el.IsNotDefined != nil {
|
|
if len(el.TextMatches) > 0 || len(el.Params) > 0 {
|
|
return nil, fmt.Errorf("carddav: failed to parse prop-filter: if is-not-defined is provided, text-match or param-filter can't be provided")
|
|
}
|
|
pf.IsNotDefined = true
|
|
}
|
|
for _, tm := range el.TextMatches {
|
|
pf.TextMatches = append(pf.TextMatches, *decodeTextMatch(&tm))
|
|
}
|
|
for _, paramEl := range el.Params {
|
|
param, err := decodeParamFilter(¶mEl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pf.Params = append(pf.Params, *param)
|
|
}
|
|
return pf, nil
|
|
}
|
|
|
|
func decodeParamFilter(el *paramFilter) (*ParamFilter, error) {
|
|
pf := &ParamFilter{Name: el.Name}
|
|
if el.IsNotDefined != nil {
|
|
if el.TextMatch != nil {
|
|
return nil, fmt.Errorf("carddav: failed to parse param-filter: if is-not-defined is provided, text-match can't be provided")
|
|
}
|
|
pf.IsNotDefined = true
|
|
}
|
|
if el.TextMatch != nil {
|
|
pf.TextMatch = decodeTextMatch(el.TextMatch)
|
|
}
|
|
return pf, nil
|
|
}
|
|
|
|
func decodeTextMatch(tm *textMatch) *TextMatch {
|
|
return &TextMatch{
|
|
Text: tm.Text,
|
|
NegateCondition: bool(tm.NegateCondition),
|
|
MatchType: MatchType(tm.MatchType),
|
|
}
|
|
}
|
|
|
|
func decodeAddressDataReq(addressData *addressDataReq) (*AddressDataRequest, error) {
|
|
if addressData.Allprop != nil && len(addressData.Props) > 0 {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: only one of allprop or prop can be specified in address-data")
|
|
}
|
|
|
|
req := &AddressDataRequest{AllProp: addressData.Allprop != nil}
|
|
for _, p := range addressData.Props {
|
|
req.Props = append(req.Props, p.Name)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (h *Handler) handleQuery(ctx context.Context, w http.ResponseWriter, query *addressbookQuery) error {
|
|
var q AddressBookQuery
|
|
if query.Prop != nil {
|
|
var addressData addressDataReq
|
|
if err := query.Prop.Decode(&addressData); err != nil && !internal.IsNotFound(err) {
|
|
return err
|
|
}
|
|
req, err := decodeAddressDataReq(&addressData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q.DataRequest = *req
|
|
}
|
|
q.FilterTest = FilterTest(query.Filter.Test)
|
|
for _, el := range query.Filter.Props {
|
|
pf, err := decodePropFilter(&el)
|
|
if err != nil {
|
|
return &internal.HTTPError{http.StatusBadRequest, err}
|
|
}
|
|
q.PropFilters = append(q.PropFilters, *pf)
|
|
}
|
|
if query.Limit != nil {
|
|
q.Limit = int(query.Limit.NResults)
|
|
if q.Limit <= 0 {
|
|
return internal.ServeMultistatus(w, internal.NewMultistatus())
|
|
}
|
|
}
|
|
|
|
aos, err := h.Backend.QueryAddressObjects(ctx, &q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var resps []internal.Response
|
|
for _, ao := range aos {
|
|
b := backend{h.Backend}
|
|
propfind := internal.Propfind{
|
|
Prop: query.Prop,
|
|
AllProp: query.AllProp,
|
|
PropName: query.PropName,
|
|
}
|
|
resp, err := b.propfindAddressObject(ctx, &propfind, &ao)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
|
|
ms := internal.NewMultistatus(resps...)
|
|
return internal.ServeMultistatus(w, ms)
|
|
}
|
|
|
|
func (h *Handler) handleMultiget(ctx context.Context, w http.ResponseWriter, multiget *addressbookMultiget) error {
|
|
var dataReq AddressDataRequest
|
|
if multiget.Prop != nil {
|
|
var addressData addressDataReq
|
|
if err := multiget.Prop.Decode(&addressData); err != nil && !internal.IsNotFound(err) {
|
|
return err
|
|
}
|
|
decoded, err := decodeAddressDataReq(&addressData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dataReq = *decoded
|
|
}
|
|
|
|
var resps []internal.Response
|
|
for _, href := range multiget.Hrefs {
|
|
ao, err := h.Backend.GetAddressObject(ctx, href.Path, &dataReq)
|
|
if err != nil {
|
|
resp := internal.NewErrorResponse(href.Path, err)
|
|
resps = append(resps, *resp)
|
|
continue
|
|
}
|
|
|
|
b := backend{h.Backend}
|
|
propfind := internal.Propfind{
|
|
Prop: multiget.Prop,
|
|
AllProp: multiget.AllProp,
|
|
PropName: multiget.PropName,
|
|
}
|
|
resp, err := b.propfindAddressObject(ctx, &propfind, ao)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
|
|
ms := internal.NewMultistatus(resps...)
|
|
return internal.ServeMultistatus(w, ms)
|
|
}
|
|
|
|
type backend struct {
|
|
Backend Backend
|
|
}
|
|
|
|
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
|
|
caps = []string{"addressbook"}
|
|
|
|
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
|
|
}
|
|
|
|
var dataReq AddressDataRequest
|
|
_, err = b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
|
|
if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
|
|
return caps, []string{http.MethodOptions, http.MethodPut}, nil
|
|
} else if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return caps, []string{
|
|
http.MethodOptions,
|
|
http.MethodHead,
|
|
http.MethodGet,
|
|
http.MethodPut,
|
|
http.MethodDelete,
|
|
"PROPFIND",
|
|
}, nil
|
|
}
|
|
|
|
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
|
var dataReq AddressDataRequest
|
|
if r.Method != http.MethodHead {
|
|
dataReq.AllProp = true
|
|
}
|
|
ao, err := b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w.Header().Set("Content-Type", vcard.MIMEType)
|
|
if ao.ETag != "" {
|
|
w.Header().Set("ETag", internal.ETag(ao.ETag).String())
|
|
}
|
|
if !ao.ModTime.IsZero() {
|
|
w.Header().Set("Last-Modified", ao.ModTime.UTC().Format(http.TimeFormat))
|
|
}
|
|
|
|
if r.Method != http.MethodHead {
|
|
return vcard.NewEncoder(w).Encode(ao.Card)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 == 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(r.Context(), propfind, ab)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resps = append(resps, *resp)
|
|
|
|
if depth != internal.DepthZero {
|
|
aos, err := b.Backend.ListAddressObjects(r.Context(), &dataReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, ao := range aos {
|
|
resp, err := b.propfindAddressObject(r.Context(), propfind, &ao)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
}
|
|
} else {
|
|
ao, err := b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := b.propfindAddressObject(r.Context(), propfind, ao)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
|
|
return internal.NewMultistatus(resps...), nil
|
|
}
|
|
|
|
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
|
|
},
|
|
internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.DisplayName{Name: ab.Name}, nil
|
|
},
|
|
addressBookDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &addressbookDescription{Description: ab.Description}, nil
|
|
},
|
|
supportedAddressDataName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &supportedAddressData{
|
|
Types: []addressDataType{
|
|
{ContentType: vcard.MIMEType, Version: "3.0"},
|
|
{ContentType: vcard.MIMEType, Version: "4.0"},
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
if ab.MaxResourceSize > 0 {
|
|
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &maxResourceSize{Size: ab.MaxResourceSize}, nil
|
|
}
|
|
}
|
|
|
|
return internal.NewPropfindResponse(ab.Path, propfind, props)
|
|
}
|
|
|
|
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
|
|
},
|
|
// TODO: address-data can only be used in REPORT requests
|
|
addressDataName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
var buf bytes.Buffer
|
|
if err := vcard.NewEncoder(&buf).Encode(ao.Card); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &addressDataResp{Data: buf.Bytes()}, nil
|
|
},
|
|
}
|
|
|
|
if !ao.ModTime.IsZero() {
|
|
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetLastModified{LastModified: internal.Time(ao.ModTime)}, nil
|
|
}
|
|
}
|
|
|
|
if ao.ETag != "" {
|
|
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetETag{ETag: internal.ETag(ao.ETag)}, nil
|
|
}
|
|
}
|
|
|
|
return internal.NewPropfindResponse(ao.Path, propfind, props)
|
|
}
|
|
|
|
func (b *backend) Proppatch(r *http.Request, update *internal.Propertyupdate) (*internal.Response, error) {
|
|
// TODO: return a failed Response instead
|
|
// TODO: support PROPPATCH for address books
|
|
return nil, internal.HTTPErrorf(http.StatusForbidden, "carddav: PROPPATCH is unsupported")
|
|
}
|
|
|
|
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
|
|
if inm := r.Header.Get("If-None-Match"); inm != "" && inm != "*" {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "invalid value for If-None-Match header")
|
|
}
|
|
|
|
opts := PutAddressObjectOptions{
|
|
IfNoneMatch: r.Header.Get("If-None-Match") == "*",
|
|
IfMatch: r.Header.Get("If-Match"),
|
|
}
|
|
|
|
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err)
|
|
}
|
|
if t != vcard.MIMEType {
|
|
// TODO: send CARDDAV:supported-address-data error
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: unsupporetd Content-Type %q", t)
|
|
}
|
|
|
|
// TODO: check CARDDAV:max-resource-size precondition
|
|
card, err := vcard.NewDecoder(r.Body).Decode()
|
|
if err != nil {
|
|
// TODO: send CARDDAV:valid-address-data error
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
|
|
}
|
|
|
|
// TODO: add support for the CARDDAV:no-uid-conflict error
|
|
loc, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &internal.Href{Path: loc}, nil
|
|
}
|
|
|
|
func (b *backend) Delete(r *http.Request) error {
|
|
return b.Backend.DeleteAddressObject(r.Context(), r.URL.Path)
|
|
}
|
|
|
|
func (b *backend) Mkcol(r *http.Request) error {
|
|
return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation unsupported")
|
|
}
|
|
|
|
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
|
|
panic("TODO")
|
|
}
|
|
|
|
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
|
|
panic("TODO")
|
|
}
|
|
|
|
// https://tools.ietf.org/rfcmarkup?doc=6352#section-6.3.2.1
|
|
type PreconditionType string
|
|
|
|
const (
|
|
PreconditionNoUIDConflict PreconditionType = "no-uid-conflict"
|
|
PreconditionSupportedAddressData PreconditionType = "supported-address-data"
|
|
PreconditionValidAddressData PreconditionType = "valid-address-data"
|
|
PreconditionMaxResourceSize PreconditionType = "max-resource-size"
|
|
)
|
|
|
|
func NewPreconditionError(err PreconditionType) error {
|
|
name := xml.Name{"urn:ietf:params:xml:ns:carddav", string(err)}
|
|
elem := internal.NewRawXMLElement(name, nil, nil)
|
|
return &internal.HTTPError{
|
|
Code: 409,
|
|
Err: &internal.Error{
|
|
Raw: []internal.RawXMLValue{*elem},
|
|
},
|
|
}
|
|
}
|