go-webdav/carddav/server.go
Conrad Hoffmann cabaf3268b carddav: return multistatus response on PROPPATCH
This does not implement any actual PROPPATCH logic, but makes the server
return a proper multistatus response with errors for each property
instead of a generic HTTP error.

It also adds the distinction between requests to the address book and
those to other resources. In CardDAV, only the address book itself has
properties that make sense to change via PROPPATCH. Those are responded
to with a 501, indicating that this needs further implementation.
Requests to other resources return 405 for each property, indicating
that the resources do not support PROPPATCH at all.
2022-05-23 11:30:03 +02:00

570 lines
17 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
}
if r.URL.Path == "/.well-known/carddav" {
principalPath, err := h.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
http.Error(w, "carddav: failed to determine current user principal", http.StatusInternalServerError)
return
}
http.Redirect(w, r, principalPath, http.StatusPermanentRedirect)
return
}
var err error
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(&paramEl)
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) {
props := map[xml.Name]internal.PropfindFunc{
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
path, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, 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) {
props := map[xml.Name]internal.PropfindFunc{
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
path, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, 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) {
homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context())
if err != nil {
return nil, err
}
resp := internal.NewOKResponse(r.URL.Path)
if r.URL.Path == homeSetPath {
// TODO: support PROPPATCH for address books
for _, prop := range update.Remove {
emptyVal := internal.NewRawXMLElement(prop.Prop.XMLName, nil, nil)
if err := resp.EncodeProp(http.StatusNotImplemented, emptyVal); err != nil {
return nil, err
}
}
for _, prop := range update.Set {
emptyVal := internal.NewRawXMLElement(prop.Prop.XMLName, nil, nil)
if err := resp.EncodeProp(http.StatusNotImplemented, emptyVal); err != nil {
return nil, err
}
}
} else {
for _, prop := range update.Remove {
emptyVal := internal.NewRawXMLElement(prop.Prop.XMLName, nil, nil)
if err := resp.EncodeProp(http.StatusMethodNotAllowed, emptyVal); err != nil {
return nil, err
}
}
for _, prop := range update.Set {
emptyVal := internal.NewRawXMLElement(prop.Prop.XMLName, nil, nil)
if err := resp.EncodeProp(http.StatusMethodNotAllowed, emptyVal); err != nil {
return nil, err
}
}
}
return resp, nil
}
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},
},
}
}