Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

52 changed files with 7873 additions and 7398 deletions

View File

@ -1,9 +0,0 @@
image: alpine/edge
packages:
- go
sources:
- https://github.com/emersion/go-webdav
tasks:
- test: |
cd go-webdav
go test -v ./...

View File

@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Question
url: "https://web.libera.chat/gamja/#emersion"
about: "Please ask questions in #emersion on Libera Chat"

View File

@ -1,12 +0,0 @@
---
name: Bug report or feature request
about: Report a bug or request a new feature
---
<!--
Please read the following before submitting a new issue:
Do NOT create GitHub issues if you have a question about go-webdav or about WebDAV in general. Ask questions on IRC in #emersion on Libera Chat.
-->

1
.gitignore vendored
View File

@ -12,4 +12,3 @@
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
.idea/

43
LICENSE
View File

@ -1,21 +1,28 @@
The MIT License (MIT)
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright (c) 2017 emersion
Copyright (c) 2020 Simon Ser
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,13 +1,10 @@
# go-webdav
[![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-webdav.svg)](https://pkg.go.dev/github.com/emersion/go-webdav)
[![GoDoc](https://godoc.org/github.com/emersion/go-webdav?status.svg)](https://godoc.org/github.com/emersion/go-webdav)
A Go library for [WebDAV], [CalDAV] and [CardDAV].
A Go library for [WebDAV](https://tools.ietf.org/html/rfc4918) and
[CardDAV](https://tools.ietf.org/html/rfc6352).
## License
MIT
[WebDAV]: https://tools.ietf.org/html/rfc4918
[CalDAV]: https://tools.ietf.org/html/rfc4791
[CardDAV]: https://tools.ietf.org/html/rfc6352
BSD

View File

@ -1,127 +0,0 @@
// Package caldav provides a client and server CalDAV implementation.
//
// CalDAV is defined in RFC 4791.
package caldav
import (
"fmt"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
)
var CapabilityCalendar = webdav.Capability("calendar-access")
func NewCalendarHomeSet(path string) webdav.BackendSuppliedHomeSet {
return &calendarHomeSet{Href: internal.Href{Path: path}}
}
// ValidateCalendarObject checks the validity of a calendar object according to
// the contraints layed out in RFC 4791 section 4.1 and returns the only event
// type and UID occuring in this calendar, or an error if the calendar could
// not be validated.
func ValidateCalendarObject(cal *ical.Calendar) (eventType string, uid string, err error) {
// Calendar object resources contained in calendar collections
// MUST NOT specify the iCalendar METHOD property.
if prop := cal.Props.Get(ical.PropMethod); prop != nil {
return "", "", fmt.Errorf("calendar resource must not specify METHOD property")
}
for _, comp := range cal.Children {
// Calendar object resources contained in calendar collections
// MUST NOT contain more than one type of calendar component
// (e.g., VEVENT, VTODO, VJOURNAL, VFREEBUSY, etc.) with the
// exception of VTIMEZONE components, which MUST be specified
// for each unique TZID parameter value specified in the
// iCalendar object.
if comp.Name != ical.CompTimezone {
if eventType == "" {
eventType = comp.Name
}
if eventType != comp.Name {
return "", "", fmt.Errorf("conflicting event types in calendar: %s, %s", eventType, comp.Name)
}
// TODO check VTIMEZONE for each TZID?
}
// Calendar components in a calendar collection that have
// different UID property values MUST be stored in separate
// calendar object resources.
compUID, err := comp.Props.Text(ical.PropUID)
if err != nil {
return "", "", fmt.Errorf("error checking component UID: %v", err)
}
if uid == "" {
uid = compUID
}
if compUID != "" && uid != compUID {
return "", "", fmt.Errorf("conflicting UID values in calendar: %s, %s", uid, compUID)
}
}
return eventType, uid, nil
}
type Calendar struct {
Path string
Name string
Description string
MaxResourceSize int64
SupportedComponentSet []string
}
type CalendarCompRequest struct {
Name string
AllProps bool
Props []string
AllComps bool
Comps []CalendarCompRequest
}
type CompFilter struct {
Name string
IsNotDefined bool
Start, End time.Time
Props []PropFilter
Comps []CompFilter
}
type ParamFilter struct {
Name string
IsNotDefined bool
TextMatch *TextMatch
}
type PropFilter struct {
Name string
IsNotDefined bool
Start, End time.Time
TextMatch *TextMatch
ParamFilter []ParamFilter
}
type TextMatch struct {
Text string
NegateCondition bool
}
type CalendarQuery struct {
CompRequest CalendarCompRequest
CompFilter CompFilter
}
type CalendarMultiGet struct {
Paths []string
CompRequest CalendarCompRequest
}
type CalendarObject struct {
Path string
ModTime time.Time
ContentLength int64
ETag string
Data *ical.Calendar
}

View File

@ -1,376 +0,0 @@
package caldav
import (
"bytes"
"context"
"fmt"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
)
// DiscoverContextURL performs a DNS-based CardDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "caldavs", domain)
}
// Client provides access to a remote CardDAV server.
type Client struct {
*webdav.Client
ic *internal.Client
}
func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
wc, err := webdav.NewClient(c, endpoint)
if err != nil {
return nil, err
}
ic, err := internal.NewClient(c, endpoint)
if err != nil {
return nil, err
}
return &Client{wc, ic}, nil
}
func (c *Client) FindCalendarHomeSet(ctx context.Context, principal string) (string, error) {
propfind := internal.NewPropNamePropFind(calendarHomeSetName)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind)
if err != nil {
return "", err
}
var prop calendarHomeSet
if err := resp.DecodeProp(&prop); err != nil {
return "", err
}
return prop.Href.Path, nil
}
func (c *Client) FindCalendars(ctx context.Context, calendarHomeSet string) ([]Calendar, error) {
propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName,
internal.DisplayNameName,
calendarDescriptionName,
maxResourceSizeName,
supportedCalendarComponentSetName,
)
ms, err := c.ic.PropFind(ctx, calendarHomeSet, internal.DepthOne, propfind)
if err != nil {
return nil, err
}
l := make([]Calendar, 0, len(ms.Responses))
for _, resp := range ms.Responses {
path, err := resp.Path()
if err != nil {
return nil, err
}
var resType internal.ResourceType
if err := resp.DecodeProp(&resType); err != nil {
return nil, err
}
if !resType.Is(calendarName) {
continue
}
var desc calendarDescription
if err := resp.DecodeProp(&desc); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var dispName internal.DisplayName
if err := resp.DecodeProp(&dispName); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var maxResSize maxResourceSize
if err := resp.DecodeProp(&maxResSize); err != nil && !internal.IsNotFound(err) {
return nil, err
}
if maxResSize.Size < 0 {
return nil, fmt.Errorf("carddav: max-resource-size must be a positive integer")
}
var supportedCompSet supportedCalendarComponentSet
if err := resp.DecodeProp(&supportedCompSet); err != nil && !internal.IsNotFound(err) {
return nil, err
}
compNames := make([]string, 0, len(supportedCompSet.Comp))
for _, comp := range supportedCompSet.Comp {
compNames = append(compNames, comp.Name)
}
l = append(l, Calendar{
Path: path,
Name: dispName.Name,
Description: desc.Description,
MaxResourceSize: maxResSize.Size,
SupportedComponentSet: compNames,
})
}
return l, nil
}
func encodeCalendarCompReq(c *CalendarCompRequest) (*comp, error) {
encoded := comp{Name: c.Name}
if c.AllProps {
encoded.Allprop = &struct{}{}
}
for _, name := range c.Props {
encoded.Prop = append(encoded.Prop, prop{Name: name})
}
if c.AllComps {
encoded.Allcomp = &struct{}{}
}
for _, child := range c.Comps {
encodedChild, err := encodeCalendarCompReq(&child)
if err != nil {
return nil, err
}
encoded.Comp = append(encoded.Comp, *encodedChild)
}
return &encoded, nil
}
func encodeCalendarReq(c *CalendarCompRequest) (*internal.Prop, error) {
compReq, err := encodeCalendarCompReq(c)
if err != nil {
return nil, err
}
calDataReq := calendarDataReq{Comp: compReq}
getLastModReq := internal.NewRawXMLElement(internal.GetLastModifiedName, nil, nil)
getETagReq := internal.NewRawXMLElement(internal.GetETagName, nil, nil)
return internal.EncodeProp(&calDataReq, getLastModReq, getETagReq)
}
func encodeCompFilter(filter *CompFilter) *compFilter {
encoded := compFilter{Name: filter.Name}
if !filter.Start.IsZero() || !filter.End.IsZero() {
encoded.TimeRange = &timeRange{
Start: dateWithUTCTime(filter.Start),
End: dateWithUTCTime(filter.End),
}
}
for _, child := range filter.Comps {
encoded.CompFilters = append(encoded.CompFilters, *encodeCompFilter(&child))
}
return &encoded
}
func decodeCalendarObjectList(ms *internal.MultiStatus) ([]CalendarObject, error) {
addrs := make([]CalendarObject, 0, len(ms.Responses))
for _, resp := range ms.Responses {
path, err := resp.Path()
if err != nil {
return nil, err
}
var calData calendarDataResp
if err := resp.DecodeProp(&calData); err != nil {
return nil, err
}
var getLastMod internal.GetLastModified
if err := resp.DecodeProp(&getLastMod); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getETag internal.GetETag
if err := resp.DecodeProp(&getETag); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getContentLength internal.GetContentLength
if err := resp.DecodeProp(&getContentLength); err != nil && !internal.IsNotFound(err) {
return nil, err
}
r := bytes.NewReader(calData.Data)
data, err := ical.NewDecoder(r).Decode()
if err != nil {
return nil, err
}
addrs = append(addrs, CalendarObject{
Path: path,
ModTime: time.Time(getLastMod.LastModified),
ContentLength: getContentLength.Length,
ETag: string(getETag.ETag),
Data: data,
})
}
return addrs, nil
}
func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *CalendarQuery) ([]CalendarObject, error) {
propReq, err := encodeCalendarReq(&query.CompRequest)
if err != nil {
return nil, err
}
calendarQuery := calendarQuery{Prop: propReq}
calendarQuery.Filter.CompFilter = *encodeCompFilter(&query.CompFilter)
req, err := c.ic.NewXMLRequest("REPORT", calendar, &calendarQuery)
if err != nil {
return nil, err
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return decodeCalendarObjectList(ms)
}
func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *CalendarMultiGet) ([]CalendarObject, error) {
propReq, err := encodeCalendarReq(&multiGet.CompRequest)
if err != nil {
return nil, err
}
calendarMultiget := calendarMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 {
href := internal.Href{Path: path}
calendarMultiget.Hrefs = []internal.Href{href}
} else {
calendarMultiget.Hrefs = make([]internal.Href, len(multiGet.Paths))
for i, p := range multiGet.Paths {
calendarMultiget.Hrefs[i] = internal.Href{Path: p}
}
}
req, err := c.ic.NewXMLRequest("REPORT", path, &calendarMultiget)
if err != nil {
return nil, err
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return decodeCalendarObjectList(ms)
}
func populateCalendarObject(co *CalendarObject, h http.Header) error {
if loc := h.Get("Location"); loc != "" {
u, err := url.Parse(loc)
if err != nil {
return err
}
co.Path = u.Path
}
if etag := h.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag)
if err != nil {
return err
}
co.ETag = etag
}
if contentLength := h.Get("Content-Length"); contentLength != "" {
n, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return err
}
co.ContentLength = n
}
if lastModified := h.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified)
if err != nil {
return err
}
co.ModTime = t
}
return nil
}
func (c *Client) GetCalendarObject(ctx context.Context, path string) (*CalendarObject, error) {
req, err := c.ic.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", ical.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer resp.Body.Close()
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return nil, err
}
if !strings.EqualFold(mediaType, ical.MIMEType) {
return nil, fmt.Errorf("caldav: expected Content-Type %q, got %q", ical.MIMEType, mediaType)
}
cal, err := ical.NewDecoder(resp.Body).Decode()
if err != nil {
return nil, err
}
co := &CalendarObject{
Path: resp.Request.URL.Path,
Data: cal,
}
if err := populateCalendarObject(co, resp.Header); err != nil {
return nil, err
}
return co, nil
}
func (c *Client) PutCalendarObject(ctx context.Context, path string, cal *ical.Calendar) (*CalendarObject, error) {
// TODO: add support for If-None-Match and If-Match
// TODO: some servers want a Content-Length header, so we can't stream the
// request body here. See the Radicale issue:
// https://github.com/Kozea/Radicale/issues/1016
var buf bytes.Buffer
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
return nil, err
}
req, err := c.ic.NewRequest(http.MethodPut, path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", ical.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
resp.Body.Close()
co := &CalendarObject{Path: path}
if err := populateCalendarObject(co, resp.Header); err != nil {
return nil, err
}
return co, nil
}

View File

@ -1,237 +0,0 @@
package caldav
import (
"encoding/xml"
"fmt"
"time"
"github.com/emersion/go-webdav/internal"
)
const namespace = "urn:ietf:params:xml:ns:caldav"
var (
calendarHomeSetName = xml.Name{namespace, "calendar-home-set"}
calendarDescriptionName = xml.Name{namespace, "calendar-description"}
supportedCalendarDataName = xml.Name{namespace, "supported-calendar-data"}
supportedCalendarComponentSetName = xml.Name{namespace, "supported-calendar-component-set"}
maxResourceSizeName = xml.Name{namespace, "max-resource-size"}
calendarQueryName = xml.Name{namespace, "calendar-query"}
calendarMultigetName = xml.Name{namespace, "calendar-multiget"}
calendarName = xml.Name{namespace, "calendar"}
calendarDataName = xml.Name{namespace, "calendar-data"}
)
// https://tools.ietf.org/html/rfc4791#section-6.2.1
type calendarHomeSet struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set"`
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"`
Description string `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc4791#section-5.2.4
type supportedCalendarData struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-data"`
Types []calendarDataType `xml:"calendar-data"`
}
// https://tools.ietf.org/html/rfc4791#section-5.2.3
type supportedCalendarComponentSet struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"`
Comp []comp `xml:"comp"`
}
// https://tools.ietf.org/html/rfc4791#section-9.6
type calendarDataType struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
ContentType string `xml:"content-type,attr"`
Version string `xml:"version,attr"`
}
// https://tools.ietf.org/html/rfc4791#section-5.2.5
type maxResourceSize struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav max-resource-size"`
Size int64 `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc4791#section-9.5
type calendarQuery struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-query"`
Prop *internal.Prop `xml:"DAV: prop,omitempty"`
AllProp *struct{} `xml:"DAV: allprop,omitempty"`
PropName *struct{} `xml:"DAV: propname,omitempty"`
Filter filter `xml:"filter"`
// TODO: timezone
}
// https://tools.ietf.org/html/rfc4791#section-9.10
type calendarMultiget struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-multiget"`
Hrefs []internal.Href `xml:"DAV: href"`
Prop *internal.Prop `xml:"DAV: prop,omitempty"`
AllProp *struct{} `xml:"DAV: allprop,omitempty"`
PropName *struct{} `xml:"DAV: propname,omitempty"`
}
// https://tools.ietf.org/html/rfc4791#section-9.7
type filter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav filter"`
CompFilter compFilter `xml:"comp-filter"`
}
// https://tools.ietf.org/html/rfc4791#section-9.7.1
type compFilter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav comp-filter"`
Name string `xml:"name,attr"`
IsNotDefined *struct{} `xml:"is-not-defined,omitempty"`
TimeRange *timeRange `xml:"time-range,omitempty"`
PropFilters []propFilter `xml:"prop-filter,omitempty"`
CompFilters []compFilter `xml:"comp-filter,omitempty"`
}
// https://tools.ietf.org/html/rfc4791#section-9.7.2
type propFilter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav prop-filter"`
Name string `xml:"name,attr"`
IsNotDefined *struct{} `xml:"is-not-defined,omitempty"`
TimeRange *timeRange `xml:"time-range,omitempty"`
TextMatch *textMatch `xml:"text-match,omitempty"`
ParamFilter []paramFilter `xml:"param-filter,omitempty"`
}
// https://tools.ietf.org/html/rfc4791#section-9.7.3
type paramFilter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav param-filter"`
Name string `xml:"name,attr"`
IsNotDefined *struct{} `xml:"is-not-defined,omitempty"`
TextMatch *textMatch `xml:"text-match,omitempty"`
}
// https://tools.ietf.org/html/rfc4791#section-9.7.5
type textMatch struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav text-match"`
Text string `xml:",chardata"`
Collation string `xml:"collation,attr,omitempty"`
NegateCondition negateCondition `xml:"negate-condition,attr,omitempty"`
}
type negateCondition bool
func (nc *negateCondition) UnmarshalText(b []byte) error {
switch s := string(b); s {
case "yes":
*nc = true
case "no":
*nc = false
default:
return fmt.Errorf("caldav: invalid negate-condition value: %q", s)
}
return nil
}
func (nc negateCondition) MarshalText() ([]byte, error) {
if nc {
return []byte("yes"), nil
}
return nil, nil
}
// https://tools.ietf.org/html/rfc4791#section-9.9
type timeRange struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav time-range"`
Start dateWithUTCTime `xml:"start,attr,omitempty"`
End dateWithUTCTime `xml:"end,attr,omitempty"`
}
const dateWithUTCTimeLayout = "20060102T150405Z"
// dateWithUTCTime is the "date with UTC time" format defined in RFC 5545 page
// 34.
type dateWithUTCTime time.Time
func (t *dateWithUTCTime) UnmarshalText(b []byte) error {
tt, err := time.Parse(dateWithUTCTimeLayout, string(b))
if err != nil {
return err
}
*t = dateWithUTCTime(tt)
return nil
}
func (t *dateWithUTCTime) MarshalText() ([]byte, error) {
s := time.Time(*t).Format(dateWithUTCTimeLayout)
return []byte(s), nil
}
// Request variant of https://tools.ietf.org/html/rfc4791#section-9.6
type calendarDataReq struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
Comp *comp `xml:"comp,omitempty"`
// TODO: expand, limit-recurrence-set, limit-freebusy-set
}
// https://tools.ietf.org/html/rfc4791#section-9.6.1
type comp struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav comp"`
Name string `xml:"name,attr"`
Allprop *struct{} `xml:"allprop,omitempty"`
Prop []prop `xml:"prop,omitempty"`
Allcomp *struct{} `xml:"allcomp,omitempty"`
Comp []comp `xml:"comp,omitempty"`
}
// https://tools.ietf.org/html/rfc4791#section-9.6.4
type prop struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav prop"`
Name string `xml:"name,attr"`
// TODO: novalue
}
// Response variant of https://tools.ietf.org/html/rfc4791#section-9.6
type calendarDataResp struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
Data []byte `xml:",chardata"`
}
type reportReq struct {
Query *calendarQuery
Multiget *calendarMultiget
// TODO: CALDAV:free-busy-query
}
func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var v interface{}
switch start.Name {
case calendarQueryName:
r.Query = &calendarQuery{}
v = r.Query
case calendarMultigetName:
r.Multiget = &calendarMultiget{}
v = r.Multiget
default:
return fmt.Errorf("caldav: unsupported REPORT root %q %q", start.Name.Space, start.Name.Local)
}
return d.DecodeElement(v, &start)
}
type mkcolReq struct {
XMLName xml.Name `xml:"DAV: mkcol"`
ResourceType internal.ResourceType `xml:"set>prop>resourcetype"`
DisplayName string `xml:"set>prop>displayname"`
// TODO this could theoretically contain all addressbook properties?
}

View File

@ -1,205 +0,0 @@
package caldav
import (
"strings"
"time"
"github.com/emersion/go-ical"
)
// Filter returns the filtered list of calendar objects matching the provided query.
// A nil query will return the full list of calendar objects.
func Filter(query *CalendarQuery, cos []CalendarObject) ([]CalendarObject, error) {
if query == nil {
// FIXME: should we always return a copy of the provided slice?
return cos, nil
}
var out []CalendarObject
for _, co := range cos {
ok, err := Match(query.CompFilter, &co)
if err != nil {
return nil, err
}
if !ok {
continue
}
// TODO properties are not currently filtered even if requested
out = append(out, co)
}
return out, nil
}
// Match reports whether the provided CalendarObject matches the query.
func Match(query CompFilter, co *CalendarObject) (matched bool, err error) {
if co.Data == nil || co.Data.Component == nil {
panic("request to process empty calendar object")
}
return match(query, co.Data.Component)
}
func match(filter CompFilter, comp *ical.Component) (bool, error) {
if comp.Name != filter.Name {
return filter.IsNotDefined, nil
}
var zeroDate time.Time
if filter.Start != zeroDate {
match, err := matchCompTimeRange(filter.Start, filter.End, comp)
if err != nil {
return false, err
}
if !match {
return false, nil
}
}
for _, compFilter := range filter.Comps {
match, err := matchCompFilter(compFilter, comp)
if err != nil {
return false, err
}
if !match {
return false, nil
}
}
for _, propFilter := range filter.Props {
match, err := matchPropFilter(propFilter, comp)
if err != nil {
return false, err
}
if !match {
return false, nil
}
}
return true, nil
}
func matchCompFilter(filter CompFilter, comp *ical.Component) (bool, error) {
var matches []*ical.Component
for _, child := range comp.Children {
match, err := match(filter, child)
if err != nil {
return false, err
} else if match {
matches = append(matches, child)
}
}
if len(matches) == 0 {
return filter.IsNotDefined, nil
}
return true, nil
}
func matchPropFilter(filter PropFilter, comp *ical.Component) (bool, error) {
// TODO: this only matches first field, there can be multiple
field := comp.Props.Get(filter.Name)
if field == nil {
return filter.IsNotDefined, nil
}
for _, paramFilter := range filter.ParamFilter {
if !matchParamFilter(paramFilter, field) {
return false, nil
}
}
var zeroDate time.Time
if filter.Start != zeroDate {
match, err := matchPropTimeRange(filter.Start, filter.End, field)
if err != nil {
return false, err
}
if !match {
return false, nil
}
} else if filter.TextMatch != nil {
if !matchTextMatch(*filter.TextMatch, field.Value) {
return false, nil
}
return true, nil
}
// empty prop-filter, property exists
return true, nil
}
func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error) {
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
// evaluate recurring components
rset, err := comp.RecurrenceSet(start.Location())
if err != nil {
return false, err
}
if rset != nil {
// TODO we can only set inclusive to true or false, but really the
// start time is inclusive while the end time is not :/
return len(rset.Between(start, end, true)) > 0, nil
}
// TODO handle more than just events
if comp.Name != ical.CompEvent {
return false, nil
}
event := ical.Event{comp}
eventStart, err := event.DateTimeStart(start.Location())
if err != nil {
return false, err
}
eventEnd, err := event.DateTimeEnd(end.Location())
if err != nil {
return false, err
}
// Event starts in time range
if eventStart.After(start) && (end.IsZero() || eventStart.Before(end)) {
return true, nil
}
// Event ends in time range
if eventEnd.After(start) && (end.IsZero() || eventEnd.Before(end)) {
return true, nil
}
// Event covers entire time range plus some
if eventStart.Before(start) && (!end.IsZero() && eventEnd.After(end)) {
return true, nil
}
return false, nil
}
func matchPropTimeRange(start, end time.Time, field *ical.Prop) (bool, error) {
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
ptime, err := field.DateTime(start.Location())
if err != nil {
return false, err
}
if ptime.After(start) && (end.IsZero() || ptime.Before(end)) {
return true, nil
}
return false, nil
}
func matchParamFilter(filter ParamFilter, field *ical.Prop) bool {
// TODO there can be multiple values
value := field.Params.Get(filter.Name)
if value == "" {
return filter.IsNotDefined
} else if filter.IsNotDefined {
return false
}
if filter.TextMatch != nil {
return matchTextMatch(*filter.TextMatch, value)
}
return true
}
func matchTextMatch(txt TextMatch, value string) bool {
// TODO: handle text-match collation attribute
match := strings.Contains(value, txt.Text)
if txt.NegateCondition {
match = !match
}
return match
}

View File

@ -1,311 +0,0 @@
package caldav
import (
"reflect"
"strings"
"testing"
"time"
"github.com/emersion/go-ical"
)
var dateFormat = "20060102T150405Z"
func toDate(t *testing.T, date string) time.Time {
res, err := time.ParseInLocation(dateFormat, date, time.UTC)
if err != nil {
t.Fatal(err)
}
return res
}
// Test data taken from https://datatracker.ietf.org/doc/html/rfc4791#appendix-B
// TODO add missing data
func TestFilter(t *testing.T) {
newCO := func(str string) CalendarObject {
cal, err := ical.NewDecoder(strings.NewReader(str)).Decode()
if err != nil {
t.Fatal(err)
}
return CalendarObject{
Data: cal,
}
}
event1 := newCO(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001102Z
DTSTART;TZID=US/Eastern:20060102T100000
DURATION:PT1H
SUMMARY:Event #1
Description:Go Steelers!
UID:74855313FA803DA593CD579A@example.com
END:VEVENT
END:VCALENDAR`)
event2 := newCO(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:00959BC664CA650E933C892C@example.com
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:00959BC664CA650E933C892C@example.com
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060106T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060106T120000
SUMMARY:Event #2 bis bis
UID:00959BC664CA650E933C892C@example.com
END:VEVENT
END:VCALENDAR`)
event3 := newCO(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:DC6C50A017428C5216A2F1CD@example.com
END:VEVENT
END:VCALENDAR`)
todo1 := newCO(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTODO
DTSTAMP:20060205T235335Z
DUE;VALUE=DATE:20060104
STATUS:NEEDS-ACTION
SUMMARY:Task #1
UID:DDDEEB7915FA61233B861457@example.com
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
END:VCALENDAR`)
for _, tc := range []struct {
name string
query *CalendarQuery
addrs []CalendarObject
want []CalendarObject
err error
}{
{
name: "nil-query",
query: nil,
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event1, event2, event3, todo1},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.8
name: "events only",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event1, event2, event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.1
name: "events in time range",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Start: toDate(t, "20060104T000000Z"),
End: toDate(t, "20060105T000000Z"),
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2, event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.1
name: "events in open time range (no end date)",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Start: toDate(t, "20060104T000000Z"),
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2, event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
name: "events by UID",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Props: []PropFilter{{
Name: "UID",
TextMatch: &TextMatch{
Text: "DC6C50A017428C5216A2F1CD@example.com",
},
}},
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
name: "events by description substring",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Props: []PropFilter{{
Name: "Description",
TextMatch: &TextMatch{
Text: "Steelers",
},
}},
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event1},
},
{
// Query a time range that only returns a result if recurrence is properly evaluated.
name: "recurring events in time range",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Start: toDate(t, "20060103T000000Z"),
End: toDate(t, "20060104T000000Z"),
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2},
},
// TODO add more examples
} {
t.Run(tc.name, func(t *testing.T) {
got, err := Filter(tc.query, tc.addrs)
switch {
case err != nil && tc.err == nil:
t.Fatalf("unexpected error: %+v", err)
case err != nil && tc.err != nil:
if got, want := err.Error(), tc.err.Error(); got != want {
t.Fatalf("invalid error:\ngot= %q\nwant=%q", got, want)
}
case err == nil && tc.err != nil:
t.Fatalf("expected an error:\ngot= %+v\nwant=%+v", err, tc.err)
case err == nil && tc.err == nil:
if got, want := got, tc.want; !reflect.DeepEqual(got, want) {
t.Fatalf("invalid filter values:\ngot= %+v\nwant=%+v", got, want)
}
}
})
}
}

View File

@ -1,773 +0,0 @@
package caldav
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"mime"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
)
// TODO if nothing more Caldav-specific needs to be added this should be merged with carddav.PutAddressObjectOptions
type PutCalendarObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite
// an existing resource.
IfNoneMatch webdav.ConditionalMatch
// IfMatch provides the ETag of the resource that the client intends
// to overwrite, can be ""
IfMatch webdav.ConditionalMatch
}
// Backend is a CalDAV server backend.
type Backend interface {
CalendarHomeSetPath(ctx context.Context) (string, error)
CreateCalendar(ctx context.Context, calendar *Calendar) error
ListCalendars(ctx context.Context) ([]Calendar, error)
GetCalendar(ctx context.Context, path string) (*Calendar, error)
GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error)
QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error)
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error)
DeleteCalendarObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend
}
// Handler handles CalDAV HTTP requests. It can be used to create a CalDAV
// server.
type Handler struct {
Backend Backend
Prefix string
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.Backend == nil {
http.Error(w, "caldav: no backend available", http.StatusInternalServerError)
return
}
if r.URL.Path == "/.well-known/caldav" {
principalPath, err := h.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
http.Error(w, "caldav: 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{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
hh := internal.Handler{Backend: &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, w, report.Query)
} else if report.Multiget != nil {
return h.handleMultiget(r.Context(), w, report.Multiget)
}
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: expected calendar-query or calendar-multiget element in REPORT request")
}
func decodeParamFilter(el *paramFilter) (*ParamFilter, error) {
pf := &ParamFilter{Name: el.Name}
if el.IsNotDefined != nil {
if el.TextMatch != nil {
return nil, fmt.Errorf("caldav: 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 = &TextMatch{Text: el.TextMatch.Text}
}
return pf, nil
}
func decodePropFilter(el *propFilter) (*PropFilter, error) {
pf := &PropFilter{Name: el.Name}
if el.IsNotDefined != nil {
if el.TextMatch != nil || el.TimeRange != nil || len(el.ParamFilter) > 0 {
return nil, fmt.Errorf("caldav: failed to parse prop-filter: if is-not-defined is provided, text-match, time-range, or param-filter can't be provided")
}
pf.IsNotDefined = true
}
if el.TextMatch != nil {
pf.TextMatch = &TextMatch{Text: el.TextMatch.Text}
}
if el.TimeRange != nil {
pf.Start = time.Time(el.TimeRange.Start)
pf.End = time.Time(el.TimeRange.End)
}
for _, paramEl := range el.ParamFilter {
paramFi, err := decodeParamFilter(&paramEl)
if err != nil {
return nil, err
}
pf.ParamFilter = append(pf.ParamFilter, *paramFi)
}
return pf, nil
}
func decodeCompFilter(el *compFilter) (*CompFilter, error) {
cf := &CompFilter{Name: el.Name}
if el.IsNotDefined != nil {
if el.TimeRange != nil || len(el.PropFilters) > 0 || len(el.CompFilters) > 0 {
return nil, fmt.Errorf("caldav: failed to parse comp-filter: if is-not-defined is provided, time-range, prop-filter, or comp-filter can't be provided")
}
cf.IsNotDefined = true
}
if el.TimeRange != nil {
cf.Start = time.Time(el.TimeRange.Start)
cf.End = time.Time(el.TimeRange.End)
}
for _, pfEl := range el.PropFilters {
pf, err := decodePropFilter(&pfEl)
if err != nil {
return nil, err
}
cf.Props = append(cf.Props, *pf)
}
for _, childEl := range el.CompFilters {
child, err := decodeCompFilter(&childEl)
if err != nil {
return nil, err
}
cf.Comps = append(cf.Comps, *child)
}
return cf, nil
}
func decodeComp(comp *comp) (*CalendarCompRequest, error) {
if comp == nil {
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: unexpected empty calendar-data in request")
}
if comp.Allprop != nil && len(comp.Prop) > 0 {
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: only one of allprop or prop can be specified in calendar-data")
}
if comp.Allcomp != nil && len(comp.Comp) > 0 {
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: only one of allcomp or comp can be specified in calendar-data")
}
req := &CalendarCompRequest{
AllProps: comp.Allprop != nil,
AllComps: comp.Allcomp != nil,
}
for _, p := range comp.Prop {
req.Props = append(req.Props, p.Name)
}
for _, c := range comp.Comp {
comp, err := decodeComp(&c)
if err != nil {
return nil, err
}
req.Comps = append(req.Comps, *comp)
}
return req, nil
}
func decodeCalendarDataReq(calendarData *calendarDataReq) (*CalendarCompRequest, error) {
if calendarData.Comp == nil {
return &CalendarCompRequest{
AllProps: true,
AllComps: true,
}, nil
}
return decodeComp(calendarData.Comp)
}
func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *calendarQuery) error {
var q CalendarQuery
// TODO: calendar-data in query.Prop
cf, err := decodeCompFilter(&query.Filter.CompFilter)
if err != nil {
return err
}
q.CompFilter = *cf
cos, err := h.Backend.QueryCalendarObjects(r.Context(), r.URL.Path, &q)
if err != nil {
return err
}
var resps []internal.Response
for _, co := range cos {
b := backend{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
propfind := internal.PropFind{
Prop: query.Prop,
AllProp: query.AllProp,
PropName: query.PropName,
}
resp, err := b.propFindCalendarObject(r.Context(), &propfind, &co)
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 *calendarMultiget) error {
var dataReq CalendarCompRequest
if multiget.Prop != nil {
var calendarData calendarDataReq
if err := multiget.Prop.Decode(&calendarData); err != nil && !internal.IsNotFound(err) {
return err
}
decoded, err := decodeCalendarDataReq(&calendarData)
if err != nil {
return err
}
dataReq = *decoded
}
var resps []internal.Response
for _, href := range multiget.Hrefs {
co, err := h.Backend.GetCalendarObject(ctx, href.Path, &dataReq)
if err != nil {
resp := internal.NewErrorResponse(href.Path, err)
resps = append(resps, *resp)
continue
}
b := backend{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
propfind := internal.PropFind{
Prop: multiget.Prop,
AllProp: multiget.AllProp,
PropName: multiget.PropName,
}
resp, err := b.propFindCalendarObject(ctx, &propfind, co)
if err != nil {
return err
}
resps = append(resps, *resp)
}
ms := internal.NewMultiStatus(resps...)
return internal.ServeMultiStatus(w, ms)
}
type backend struct {
Backend Backend
Prefix string
}
type resourceType int
const (
resourceTypeRoot resourceType = iota
resourceTypeUserPrincipal
resourceTypeCalendarHomeSet
resourceTypeCalendar
resourceTypeCalendarObject
)
func (b *backend) resourceTypeAtPath(reqPath string) resourceType {
p := path.Clean(reqPath)
p = strings.TrimPrefix(p, b.Prefix)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if p == "/" {
return resourceTypeRoot
}
return resourceType(len(strings.Split(p, "/")) - 1)
}
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
caps = []string{"calendar-access"}
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendarObject {
return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil
}
var dataReq CalendarCompRequest
_, err = b.Backend.GetCalendarObject(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 CalendarCompRequest
if r.Method != http.MethodHead {
dataReq.AllProps = true
}
co, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
if err != nil {
return err
}
w.Header().Set("Content-Type", ical.MIMEType)
if co.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(co.ContentLength, 10))
}
if co.ETag != "" {
w.Header().Set("ETag", internal.ETag(co.ETag).String())
}
if !co.ModTime.IsZero() {
w.Header().Set("Last-Modified", co.ModTime.UTC().Format(http.TimeFormat))
}
if r.Method != http.MethodHead {
return ical.NewEncoder(w).Encode(co.Data)
}
return nil
}
func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) {
resType := b.resourceTypeAtPath(r.URL.Path)
var dataReq CalendarCompRequest
var resps []internal.Response
switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
return nil, err
}
if r.URL.Path == principalPath {
resp, err := b.propFindUserPrincipal(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resp, err := b.propFindHomeSet(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth == internal.DepthInfinity {
resps_, err := b.propFindAllCalendars(r.Context(), propfind, true)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
}
case resourceTypeCalendarHomeSet:
homeSetPath, err := b.Backend.CalendarHomeSetPath(r.Context())
if err != nil {
return nil, err
}
if r.URL.Path == homeSetPath {
resp, err := b.propFindHomeSet(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
recurse := depth == internal.DepthInfinity
resps_, err := b.propFindAllCalendars(r.Context(), propfind, recurse)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
case resourceTypeCalendar:
ab, err := b.Backend.GetCalendar(r.Context(), r.URL.Path)
if err != nil {
return nil, err
}
resp, err := b.propFindCalendar(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
case resourceTypeCalendarObject:
ao, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
if err != nil {
return nil, err
}
resp, err := b.propFindCalendarObject(r.Context(), propfind, ao)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
}
return internal.NewMultiStatus(resps...), nil
}
func (b *backend) propFindRoot(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
homeSetPath, err := b.Backend.CalendarHomeSetPath(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
calendarHomeSetName: internal.PropFindValue(&calendarHomeSet{
Href: internal.Href{Path: homeSetPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
homeSetPath, err := b.Backend.CalendarHomeSetPath(ctx)
if err != nil {
return nil, err
}
// TODO anything else to return here?
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(homeSetPath, propfind, props)
}
func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropFind, cal *Calendar) (*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: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, calendarName)),
calendarDescriptionName: internal.PropFindValue(&calendarDescription{
Description: cal.Description,
}),
supportedCalendarDataName: internal.PropFindValue(&supportedCalendarData{
Types: []calendarDataType{
{ContentType: ical.MIMEType, Version: "2.0"},
},
}),
supportedCalendarComponentSetName: func(*internal.RawXMLValue) (interface{}, error) {
components := []comp{}
if cal.SupportedComponentSet != nil {
for _, name := range cal.SupportedComponentSet {
components = append(components, comp{Name: name})
}
} else {
components = append(components, comp{Name: ical.CompEvent})
}
return &supportedCalendarComponentSet{
Comp: components,
}, nil
},
}
if cal.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: cal.Name,
})
}
if cal.Description != "" {
props[calendarDescriptionName] = internal.PropFindValue(&calendarDescription{
Description: cal.Description,
})
}
if cal.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{
Size: cal.MaxResourceSize,
})
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
}
// TODO: CALDAV:calendar-timezone, CALDAV:supported-calendar-component-set, CALDAV:min-date-time, CALDAV:max-date-time, CALDAV:max-instances, CALDAV:max-attendees-per-instance
return internal.NewPropFindResponse(cal.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 {
return nil, err
}
var resps []internal.Response
for _, ab := range abs {
resp, err := b.propFindCalendar(ctx, propfind, &ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if recurse {
resps_, err := b.propFindAllCalendarObjects(ctx, propfind, &ab)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
return resps, nil
}
func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal.PropFind, co *CalendarObject) (*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: internal.PropFindValue(&internal.GetContentType{
Type: ical.MIMEType,
}),
// TODO: calendar-data can only be used in REPORT requests
calendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer
if err := ical.NewEncoder(&buf).Encode(co.Data); err != nil {
return nil, err
}
return &calendarDataResp{Data: buf.Bytes()}, nil
},
}
if co.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: co.ContentLength,
})
}
if !co.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(co.ModTime),
})
}
if co.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(co.ETag),
})
}
return internal.NewPropFindResponse(co.Path, propfind, props)
}
func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, cal *Calendar) ([]internal.Response, error) {
var dataReq CalendarCompRequest
aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq)
if err != nil {
return nil, err
}
var resps []internal.Response
for _, ao := range aos {
resp, err := b.propFindCalendarObject(ctx, propfind, &ao)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
}
return resps, nil
}
func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*internal.Response, error) {
return nil, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: PropPatch not implemented")
}
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
opts := PutCalendarObjectOptions{
IfNoneMatch: ifNoneMatch,
IfMatch: ifMatch,
}
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err)
}
if t != ical.MIMEType {
// TODO: send CALDAV:supported-calendar-data error
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t)
}
// TODO: check CALDAV:max-resource-size precondition
cal, err := ical.NewDecoder(r.Body).Decode()
if err != nil {
// TODO: send CALDAV:valid-calendar-data error
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
}
co, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts)
if err != nil {
return err
}
if co.ETag != "" {
w.Header().Set("ETag", internal.ETag(co.ETag).String())
}
if !co.ModTime.IsZero() {
w.Header().Set("Last-Modified", co.ModTime.UTC().Format(http.TimeFormat))
}
if co.Path != "" {
w.Header().Set("Location", co.Path)
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
return nil
}
func (b *backend) Delete(r *http.Request) error {
return b.Backend.DeleteCalendarObject(r.Context(), r.URL.Path)
}
func (b *backend) Mkcol(r *http.Request) error {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar {
return internal.HTTPErrorf(http.StatusForbidden, "caldav: calendar creation not allowed at given location")
}
cal := Calendar{
Path: r.URL.Path,
}
if !internal.IsRequestBodyEmpty(r) {
var m mkcolReq
if err := internal.DecodeXMLRequest(r, &m); err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: error parsing mkcol request: %s", err.Error())
}
if !m.ResourceType.Is(internal.CollectionName) || !m.ResourceType.Is(calendarName) {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
cal.Name = m.DisplayName
// TODO ...
}
return b.Backend.CreateCalendar(r.Context(), &cal)
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Copy not implemented")
}
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Move not implemented")
}
// https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
type PreconditionType string
const (
PreconditionNoUIDConflict PreconditionType = "no-uid-conflict"
PreconditionSupportedCalendarData PreconditionType = "supported-calendar-data"
PreconditionSupportedCalendarComponent PreconditionType = "supported-calendar-component"
PreconditionValidCalendarData PreconditionType = "valid-calendar-data"
PreconditionValidCalendarObjectResource PreconditionType = "valid-calendar-object-resource"
PreconditionCalendarCollectionLocationOk PreconditionType = "calendar-collection-location-ok"
PreconditionMaxResourceSize PreconditionType = "max-resource-size"
PreconditionMinDateTime PreconditionType = "min-date-time"
PreconditionMaxDateTime PreconditionType = "max-date-time"
PreconditionMaxInstances PreconditionType = "max-instances"
PreconditionMaxAttendeesPerInstance PreconditionType = "max-attendees-per-instance"
)
func NewPreconditionError(err PreconditionType) error {
name := xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: string(err)}
elem := internal.NewRawXMLElement(name, nil, nil)
return &internal.HTTPError{
Code: 409,
Err: &internal.Error{
Raw: []internal.RawXMLValue{*elem},
},
}
}

View File

@ -1,235 +0,0 @@
package caldav
import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/emersion/go-ical"
)
var propFindSupportedCalendarComponentRequest = `
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>
`
var testPropFindSupportedCalendarComponentCases = map[*Calendar][]string{
&Calendar{Path: "/user/calendars/cal"}: []string{"VEVENT"},
&Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VTODO"}}: []string{"VTODO"},
&Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VEVENT", "VTODO"}}: []string{"VEVENT", "VTODO"},
}
func TestPropFindSupportedCalendarComponent(t *testing.T) {
for calendar, expected := range testPropFindSupportedCalendarComponentCases {
req := httptest.NewRequest("PROPFIND", calendar.Path, nil)
req.Body = io.NopCloser(strings.NewReader(propFindSupportedCalendarComponentRequest))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}}
handler.ServeHTTP(w, req)
res := w.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
resp := string(data)
for _, comp := range expected {
// Would be nicer to do a proper XML-decoding here, but this is probably good enough for now.
if !strings.Contains(resp, comp) {
t.Errorf("Expected component: %v not found in response:\n%v", comp, resp)
}
}
}
}
var propFindUserPrincipal = `
<?xml version="1.0" encoding="UTF-8"?>
<A:propfind xmlns:A="DAV:">
<A:prop>
<A:current-user-principal/>
<A:principal-URL/>
<A:resourcetype/>
</A:prop>
</A:propfind>
`
func TestPropFindRoot(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/", strings.NewReader(propFindUserPrincipal))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
calendar := &Calendar{}
handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}}
handler.ServeHTTP(w, req)
res := w.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
resp := string(data)
if !strings.Contains(resp, `<current-user-principal xmlns="DAV:"><href>/user/</href></current-user-principal>`) {
t.Errorf("No user-principal returned when doing a PROPFIND against root, response:\n%s", resp)
}
}
var reportCalendarData = `
<?xml version="1.0" encoding="UTF-8"?>
<B:calendar-multiget xmlns:A="DAV:" xmlns:B="urn:ietf:params:xml:ns:caldav">
<A:prop>
<B:calendar-data/>
</A:prop>
<A:href>%s</A:href>
</B:calendar-multiget>
`
func TestMultiCalendarBackend(t *testing.T) {
calendarB := Calendar{Path: "/user/calendars/b", SupportedComponentSet: []string{"VTODO"}}
calendars := []Calendar{
Calendar{Path: "/user/calendars/a"},
calendarB,
}
eventSummary := "This is a todo"
event := ical.NewEvent()
event.Name = ical.CompToDo
event.Props.SetText(ical.PropUID, "46bbf47a-1861-41a3-ae06-8d8268c6d41e")
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
event.Props.SetText(ical.PropSummary, eventSummary)
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropVersion, "2.0")
cal.Props.SetText(ical.PropProductID, "-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN")
cal.Children = []*ical.Component{
event.Component,
}
object := CalendarObject{
Path: "/user/calendars/b/test.ics",
Data: cal,
}
req := httptest.NewRequest("PROPFIND", "/user/calendars/", strings.NewReader(propFindUserPrincipal))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
handler := Handler{Backend: testBackend{
calendars: calendars,
objectMap: map[string][]CalendarObject{
calendarB.Path: []CalendarObject{object},
},
}}
handler.ServeHTTP(w, req)
res := w.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
resp := string(data)
for _, calendar := range calendars {
if !strings.Contains(resp, fmt.Sprintf(`<response xmlns="DAV:"><href>%s</href>`, calendar.Path)) {
t.Errorf("Calendar: %v not returned in PROPFIND, response:\n%s", calendar, resp)
}
}
// Now do a PROPFIND for the last calendar
req = httptest.NewRequest("PROPFIND", calendarB.Path, strings.NewReader(propFindSupportedCalendarComponentRequest))
req.Header.Set("Content-Type", "application/xml")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
res = w.Result()
defer res.Body.Close()
data, err = ioutil.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
resp = string(data)
if !strings.Contains(resp, "VTODO") {
t.Errorf("Expected component: VTODO not found in response:\n%v", resp)
}
if !strings.Contains(resp, object.Path) {
t.Errorf("Expected calendar object: %v not found in response:\n%v", object, resp)
}
// Now do a REPORT to get the actual data for the event
req = httptest.NewRequest("REPORT", calendarB.Path, strings.NewReader(fmt.Sprintf(reportCalendarData, object.Path)))
req.Header.Set("Content-Type", "application/xml")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
res = w.Result()
defer res.Body.Close()
data, err = ioutil.ReadAll(res.Body)
if err != nil {
t.Error(err)
}
resp = string(data)
if !strings.Contains(resp, fmt.Sprintf("SUMMARY:%s", eventSummary)) {
t.Errorf("ICAL content not properly returned in response:\n%v", resp)
}
}
type testBackend struct {
calendars []Calendar
objectMap map[string][]CalendarObject
}
func (t testBackend) CreateCalendar(ctx context.Context, calendar *Calendar) error {
return nil
}
func (t testBackend) ListCalendars(ctx context.Context) ([]Calendar, error) {
return t.calendars, nil
}
func (t testBackend) GetCalendar(ctx context.Context, path string) (*Calendar, error) {
for _, cal := range t.calendars {
if cal.Path == path {
return &cal, nil
}
}
return nil, fmt.Errorf("Calendar for path: %s not found", path)
}
func (t testBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
return "/user/calendars/", nil
}
func (t testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return "/user/", nil
}
func (t testBackend) DeleteCalendarObject(ctx context.Context, path string) error {
return nil
}
func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) {
for _, objs := range t.objectMap {
for _, obj := range objs {
if obj.Path == path {
return &obj, nil
}
}
}
return nil, fmt.Errorf("Couldn't find calendar object at: %s", path)
}
func (t testBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) {
return nil, nil
}
func (t testBackend) ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) {
return t.objectMap[path], nil
}
func (t testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) {
return nil, nil
}

35
carddav/backend.go Normal file
View File

@ -0,0 +1,35 @@
package carddav
// TODO: add context support
import (
"errors"
"os"
"github.com/emersion/go-vcard"
)
var (
ErrNotFound = errors.New("carddav: not found")
)
type AddressBookInfo struct {
Name string
Description string
MaxResourceSize int
}
type AddressObject interface {
ID() string
Stat() (os.FileInfo, error) // can return nil, nil
Card() (vcard.Card, error)
SetCard(vcard.Card) error
Remove() error
}
type AddressBook interface {
Info() (*AddressBookInfo, error)
GetAddressObject(id string) (AddressObject, error)
ListAddressObjects() ([]AddressObject, error)
CreateAddressObject(vcard.Card) (AddressObject, error)
}

View File

@ -1,124 +1,193 @@
// Package carddav provides a client and server CardDAV implementation.
//
// CardDAV is defined in RFC 6352.
// Package carddav provides a CardDAV server implementation, as defined in
// RFC 6352.
package carddav
import (
"time"
"bytes"
"encoding/xml"
"net/http"
"net/url"
"os"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
"golang.org/x/net/context"
"log"
)
var CapabilityAddressBook = webdav.Capability("addressbook")
var addressDataName = xml.Name{Space: "urn:ietf:params:xml:ns:carddav", Local: "address-data"}
func NewAddressBookHomeSet(path string) webdav.BackendSuppliedHomeSet {
return &addressbookHomeSet{Href: internal.Href{Path: path}}
type responseWriter struct {
http.ResponseWriter
}
type AddressDataType struct {
ContentType string
Version string
func (w responseWriter) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}
type AddressBook struct {
Path string
Name string
Description string
MaxResourceSize int64
SupportedAddressData []AddressDataType
type Handler struct {
ab AddressBook
webdav *webdav.Handler
}
func (ab *AddressBook) SupportsAddressData(contentType, version string) bool {
if len(ab.SupportedAddressData) == 0 {
return contentType == "text/vcard" && version == "3.0"
func NewHandler(ab AddressBook) *Handler {
return &Handler{
ab: ab,
webdav: &webdav.Handler{
FileSystem: &fileSystem{ab},
Logger: func(req *http.Request, err error) {
if err != nil {
log.Println("ERROR", req, err)
}
},
},
}
for _, t := range ab.SupportedAddressData {
if t.ContentType == contentType && t.Version == version {
return true
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
w.Header().Add("DAV", "addressbook")
}
w = responseWriter{w}
switch r.Method {
case "REPORT":
code, err := h.handleReport(w, r)
if err != nil {
if code == 0 {
code = http.StatusInternalServerError
}
http.Error(w, err.Error(), code)
}
case "OPTIONS":
w.Header().Add("Allow", "REPORT")
fallthrough
default:
h.webdav.ServeHTTP(w, r)
}
}
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) (int, error) {
var mg addressbookMultiget
if err := xml.NewDecoder(r.Body).Decode(&mg); err != nil {
return http.StatusBadRequest, err
}
mw := webdav.NewMultistatusWriter(w)
defer mw.Close()
if len(mg.Href) == 0 {
mg.Href = []string{r.URL.Path}
}
for _, href := range mg.Href {
pstats, status, err := multiget(r.Context(), h.webdav.FileSystem, h.webdav.LockSystem, href, []xml.Name(mg.Prop), mg.Allprop != nil)
if err != nil {
return status, err
}
resp := &webdav.Response{
Href: []string{(&url.URL{Path: href}).EscapedPath()},
Status: status,
Propstat: pstats,
}
if err := mw.Write(resp); err != nil {
return 0, err
}
}
return false
return 0, nil
}
type AddressBookQuery struct {
DataRequest AddressDataRequest
func multiget(ctx context.Context, fs webdav.FileSystem, ls webdav.LockSystem, name string, pnames []xml.Name, allprop bool) ([]webdav.Propstat, int, error) {
wantAddressData := false
for i, pname := range pnames {
if pname == addressDataName {
pnames = append(pnames[:i], pnames[i+1:]...)
wantAddressData = true
break
}
}
PropFilters []PropFilter
FilterTest FilterTest // defaults to FilterAnyOf
var pstats []webdav.Propstat
var err error
if allprop {
wantAddressData = true
pstats, err = webdav.Allprop(ctx, fs, ls, name, pnames)
} else {
pstats, err = webdav.Props(ctx, fs, ls, name, pnames)
}
if err != nil {
return pstats, 0, err
}
Limit int // <= 0 means unlimited
// TODO: locking stuff
f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return nil, http.StatusNotFound, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, http.StatusNotFound, err
}
if wantAddressData {
if fi.IsDir() {
// TODO
return nil, http.StatusNotFound, err
}
prop, status, err := addressdata(f.(*file).ao)
if err != nil {
return nil, status, err
}
if status == 0 {
status = http.StatusOK
}
inserted := false
for i, pstat := range pstats {
if pstat.Status == status {
pstats[i].Props = append(pstat.Props, prop)
inserted = true
break
}
}
if !inserted {
pstats = append(pstats, webdav.Propstat{
Props: []webdav.Property{prop},
Status: status,
})
}
}
return pstats, 0, nil
}
type AddressDataRequest struct {
Props []string
AllProp bool
}
type PropFilter struct {
Name string
Test FilterTest // defaults to FilterAnyOf
// if IsNotDefined is set, TextMatches and Params need to be unset
IsNotDefined bool
TextMatches []TextMatch
Params []ParamFilter
}
type ParamFilter struct {
Name string
// if IsNotDefined is set, TextMatch needs to be unset
IsNotDefined bool
TextMatch *TextMatch
}
type TextMatch struct {
Text string
NegateCondition bool
MatchType MatchType // defaults to MatchContains
}
type FilterTest string
const (
FilterAnyOf FilterTest = "anyof"
FilterAllOf FilterTest = "allof"
)
type MatchType string
const (
MatchEquals MatchType = "equals"
MatchContains MatchType = "contains"
MatchStartsWith MatchType = "starts-with"
MatchEndsWith MatchType = "ends-with"
)
type AddressBookMultiGet struct {
Paths []string
DataRequest AddressDataRequest
}
type AddressObject struct {
Path string
ModTime time.Time
ContentLength int64
ETag string
Card vcard.Card
}
// SyncQuery is the query struct represents a sync-collection request
type SyncQuery struct {
DataRequest AddressDataRequest
SyncToken string
Limit int // <= 0 means unlimited
}
// SyncResponse contains the returned sync-token for next time
type SyncResponse struct {
SyncToken string
Updated []AddressObject
Deleted []string
func addressdata(ao AddressObject) (webdav.Property, int, error) {
prop := webdav.Property{XMLName: addressDataName}
card, err := ao.Card()
if err != nil {
return prop, 0, err
}
// TODO: restrict to requested props
var b bytes.Buffer
if err := vcard.NewEncoder(&b).Encode(card); err != nil {
return prop, 0, err
}
var escaped bytes.Buffer
if err := xml.EscapeText(&escaped, b.Bytes()); err != nil {
return prop, 0, err
}
prop.InnerXML = escaped.Bytes()
return prop, 0, nil
}

View File

@ -1,242 +0,0 @@
package carddav
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
)
type testBackend struct {
addressBooks []AddressBook
}
type contextKey string
var (
aliceData = `BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1
FN;PID=1.1:Alice Gopher
N:Gopher;Alice;;;
EMAIL;PID=1.1:alice@example.com
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0551
END:VCARD`
alicePath = "urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1.vcf"
currentUserPrincipalKey = contextKey("test:currentUserPrincipal")
homeSetPathKey = contextKey("test:homeSetPath")
addressBookPathKey = contextKey("test:addressBookPath")
)
func (*testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
r := ctx.Value(currentUserPrincipalKey).(string)
return r, nil
}
func (*testBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
r := ctx.Value(homeSetPathKey).(string)
return r, nil
}
func (*testBackend) ListAddressBooks(ctx context.Context) ([]AddressBook, error) {
p := ctx.Value(addressBookPathKey).(string)
return []AddressBook{
AddressBook{
Path: p,
Name: "My contacts",
Description: "Default address book",
MaxResourceSize: 1024,
SupportedAddressData: nil,
},
}, nil
}
func (b *testBackend) GetAddressBook(ctx context.Context, path string) (*AddressBook, error) {
abs, err := b.ListAddressBooks(ctx)
if err != nil {
panic(err)
}
for _, ab := range abs {
if ab.Path == path {
return &ab, nil
}
}
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
func (b *testBackend) CreateAddressBook(ctx context.Context, ab *AddressBook) error {
b.addressBooks = append(b.addressBooks, *ab)
return nil
}
func (*testBackend) DeleteAddressBook(ctx context.Context, path string) error {
panic("TODO: implement")
}
func (*testBackend) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error) {
if path == alicePath {
card, err := vcard.NewDecoder(strings.NewReader(aliceData)).Decode()
if err != nil {
return nil, err
}
return &AddressObject{
Path: path,
Card: card,
}, nil
} else {
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
}
func (b *testBackend) ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error) {
p := ctx.Value(addressBookPathKey).(string)
if !strings.HasPrefix(path, p) {
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
alice, err := b.GetAddressObject(ctx, alicePath, req)
if err != nil {
return nil, err
}
return []AddressObject{*alice}, nil
}
func (*testBackend) QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error) {
panic("TODO: implement")
}
func (*testBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, error) {
panic("TODO: implement")
}
func (*testBackend) DeleteAddressObject(ctx context.Context, path string) error {
panic("TODO: implement")
}
func TestAddressBookDiscovery(t *testing.T) {
for _, tc := range []struct {
name string
prefix string
currentUserPrincipal string
homeSetPath string
addressBookPath string
}{
{
name: "simple",
prefix: "",
currentUserPrincipal: "/test/",
homeSetPath: "/test/contacts/",
addressBookPath: "/test/contacts/private",
},
{
name: "prefix",
prefix: "/dav",
currentUserPrincipal: "/dav/test/",
homeSetPath: "/dav/test/contacts/",
addressBookPath: "/dav/test/contacts/private",
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
h := Handler{&testBackend{}, tc.prefix}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, currentUserPrincipalKey, tc.currentUserPrincipal)
ctx = context.WithValue(ctx, homeSetPathKey, tc.homeSetPath)
ctx = context.WithValue(ctx, addressBookPathKey, tc.addressBookPath)
r = r.WithContext(ctx)
(&h).ServeHTTP(w, r)
}))
defer ts.Close()
// client supports .well-known discovery if explicitly pointed to it
startURL := ts.URL
if tc.currentUserPrincipal != "/" {
startURL = ts.URL + "/.well-known/carddav"
}
client, err := NewClient(nil, startURL)
if err != nil {
t.Fatalf("error creating client: %s", err)
}
cup, err := client.FindCurrentUserPrincipal(ctx)
if err != nil {
t.Fatalf("error finding user principal url: %s", err)
}
if cup != tc.currentUserPrincipal {
t.Fatalf("Found current user principal URL '%s', expected '%s'", cup, tc.currentUserPrincipal)
}
hsp, err := client.FindAddressBookHomeSet(ctx, cup)
if err != nil {
t.Fatalf("error finding home set path: %s", err)
}
if hsp != tc.homeSetPath {
t.Fatalf("Found home set path '%s', expected '%s'", hsp, tc.homeSetPath)
}
abs, err := client.FindAddressBooks(ctx, hsp)
if err != nil {
t.Fatalf("error finding address books: %s", err)
}
if len(abs) != 1 {
t.Fatalf("Found %d address books, expected 1", len(abs))
}
if abs[0].Path != tc.addressBookPath {
t.Fatalf("Found address book at %s, expected %s", abs[0].Path, tc.addressBookPath)
}
})
}
}
var mkcolRequestBody = `
<?xml version="1.0" encoding="utf-8" ?>
<D:mkcol xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:set>
<D:prop>
<D:resourcetype>
<D:collection/>
<C:addressbook/>
</D:resourcetype>
<D:displayname>Lisa's Contacts</D:displayname>
<C:addressbook-description xml:lang="en"
>My primary address book.</C:addressbook-description>
</D:prop>
</D:set>
</D:mkcol>`
func TestCreateAddressbookMinimalBody(t *testing.T) {
tb := testBackend{
addressBooks: nil,
}
b := backend{
Backend: &tb,
Prefix: "/dav",
}
req := httptest.NewRequest("MKCOL", "/dav/addressbooks/user0/test-addressbook", strings.NewReader(mkcolRequestBody))
req.Header.Set("Content-Type", "application/xml")
err := b.Mkcol(req)
if err != nil {
t.Fatalf("Unexpcted error in Mkcol: %s", err)
}
if len(tb.addressBooks) != 1 {
t.Fatalf("Found %d address books, expected 1", len(tb.addressBooks))
}
c := tb.addressBooks[0]
if c.Name != "Lisa's Contacts" {
t.Fatalf("Address book name is '%s', expected 'Lisa's Contacts'", c.Name)
}
if c.Path != "/dav/addressbooks/user0/test-addressbook" {
t.Fatalf("Address book path is '%s', expected '/dav/addressbooks/user0/test-addressbook'", c.Path)
}
if c.Description != "My primary address book." {
t.Fatalf("Address book sdscription is '%s', expected 'My primary address book.'", c.Description)
}
}

View File

@ -1,472 +0,0 @@
package carddav
import (
"bytes"
"context"
"fmt"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
)
// DiscoverContextURL performs a DNS-based CardDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "carddavs", domain)
}
// Client provides access to a remote CardDAV server.
type Client struct {
*webdav.Client
ic *internal.Client
}
func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
wc, err := webdav.NewClient(c, endpoint)
if err != nil {
return nil, err
}
ic, err := internal.NewClient(c, endpoint)
if err != nil {
return nil, err
}
return &Client{wc, ic}, nil
}
func (c *Client) HasSupport(ctx context.Context) error {
classes, _, err := c.ic.Options(ctx, "")
if err != nil {
return err
}
if !classes["addressbook"] {
return fmt.Errorf("carddav: server doesn't support the DAV addressbook class")
}
return nil
}
func (c *Client) FindAddressBookHomeSet(ctx context.Context, principal string) (string, error) {
propfind := internal.NewPropNamePropFind(addressBookHomeSetName)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind)
if err != nil {
return "", err
}
var prop addressbookHomeSet
if err := resp.DecodeProp(&prop); err != nil {
return "", err
}
return prop.Href.Path, nil
}
func decodeSupportedAddressData(supported *supportedAddressData) []AddressDataType {
l := make([]AddressDataType, len(supported.Types))
for i, t := range supported.Types {
l[i] = AddressDataType{t.ContentType, t.Version}
}
return l
}
func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string) ([]AddressBook, error) {
propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName,
internal.DisplayNameName,
addressBookDescriptionName,
maxResourceSizeName,
supportedAddressDataName,
)
ms, err := c.ic.PropFind(ctx, addressBookHomeSet, internal.DepthOne, propfind)
if err != nil {
return nil, err
}
l := make([]AddressBook, 0, len(ms.Responses))
for _, resp := range ms.Responses {
path, err := resp.Path()
if err != nil {
return nil, err
}
var resType internal.ResourceType
if err := resp.DecodeProp(&resType); err != nil {
return nil, err
}
if !resType.Is(addressBookName) {
continue
}
var desc addressbookDescription
if err := resp.DecodeProp(&desc); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var dispName internal.DisplayName
if err := resp.DecodeProp(&dispName); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var maxResSize maxResourceSize
if err := resp.DecodeProp(&maxResSize); err != nil && !internal.IsNotFound(err) {
return nil, err
}
if maxResSize.Size < 0 {
return nil, fmt.Errorf("carddav: max-resource-size must be a positive integer")
}
var supported supportedAddressData
if err := resp.DecodeProp(&supported); err != nil && !internal.IsNotFound(err) {
return nil, err
}
l = append(l, AddressBook{
Path: path,
Name: dispName.Name,
Description: desc.Description,
MaxResourceSize: maxResSize.Size,
SupportedAddressData: decodeSupportedAddressData(&supported),
})
}
return l, nil
}
func encodeAddressPropReq(req *AddressDataRequest) (*internal.Prop, error) {
var addrDataReq addressDataReq
if req.AllProp {
addrDataReq.Allprop = &struct{}{}
} else {
for _, name := range req.Props {
addrDataReq.Props = append(addrDataReq.Props, prop{Name: name})
}
}
getLastModReq := internal.NewRawXMLElement(internal.GetLastModifiedName, nil, nil)
getETagReq := internal.NewRawXMLElement(internal.GetETagName, nil, nil)
return internal.EncodeProp(&addrDataReq, getLastModReq, getETagReq)
}
func encodePropFilter(pf *PropFilter) (*propFilter, error) {
el := &propFilter{Name: pf.Name, Test: filterTest(pf.Test)}
if pf.IsNotDefined {
if len(pf.TextMatches) > 0 || len(pf.Params) > 0 {
return nil, fmt.Errorf("carddav: failed to encode PropFilter: IsNotDefined cannot be set with TextMatches or Params")
}
el.IsNotDefined = &struct{}{}
}
for _, tm := range pf.TextMatches {
el.TextMatches = append(el.TextMatches, *encodeTextMatch(&tm))
}
for _, param := range pf.Params {
paramEl, err := encodeParamFilter(&param)
if err != nil {
return nil, err
}
el.Params = append(el.Params, *paramEl)
}
return el, nil
}
func encodeParamFilter(pf *ParamFilter) (*paramFilter, error) {
el := &paramFilter{Name: pf.Name}
if pf.IsNotDefined {
if pf.TextMatch != nil {
return nil, fmt.Errorf("carddav: failed to encode ParamFilter: only one of IsNotDefined or TextMatch can be set")
}
el.IsNotDefined = &struct{}{}
}
if pf.TextMatch != nil {
el.TextMatch = encodeTextMatch(pf.TextMatch)
}
return el, nil
}
func encodeTextMatch(tm *TextMatch) *textMatch {
return &textMatch{
Text: tm.Text,
NegateCondition: negateCondition(tm.NegateCondition),
MatchType: matchType(tm.MatchType),
}
}
func decodeAddressList(ms *internal.MultiStatus) ([]AddressObject, error) {
addrs := make([]AddressObject, 0, len(ms.Responses))
for _, resp := range ms.Responses {
path, err := resp.Path()
if err != nil {
return nil, err
}
var addrData addressDataResp
if err := resp.DecodeProp(&addrData); err != nil {
return nil, err
}
var getLastMod internal.GetLastModified
if err := resp.DecodeProp(&getLastMod); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getETag internal.GetETag
if err := resp.DecodeProp(&getETag); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getContentLength internal.GetContentLength
if err := resp.DecodeProp(&getContentLength); err != nil && !internal.IsNotFound(err) {
return nil, err
}
r := bytes.NewReader(addrData.Data)
card, err := vcard.NewDecoder(r).Decode()
if err != nil {
return nil, err
}
addrs = append(addrs, AddressObject{
Path: path,
ModTime: time.Time(getLastMod.LastModified),
ContentLength: getContentLength.Length,
ETag: string(getETag.ETag),
Card: card,
})
}
return addrs, nil
}
func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&query.DataRequest)
if err != nil {
return nil, err
}
addressbookQuery := addressbookQuery{Prop: propReq}
addressbookQuery.Filter.Test = filterTest(query.FilterTest)
for _, pf := range query.PropFilters {
el, err := encodePropFilter(&pf)
if err != nil {
return nil, err
}
addressbookQuery.Filter.Props = append(addressbookQuery.Filter.Props, *el)
}
if query.Limit > 0 {
addressbookQuery.Limit = &limit{NResults: uint(query.Limit)}
}
req, err := c.ic.NewXMLRequest("REPORT", addressBook, &addressbookQuery)
if err != nil {
return nil, err
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return decodeAddressList(ms)
}
func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&multiGet.DataRequest)
if err != nil {
return nil, err
}
addressbookMultiget := addressbookMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 {
href := internal.Href{Path: path}
addressbookMultiget.Hrefs = []internal.Href{href}
} else {
addressbookMultiget.Hrefs = make([]internal.Href, len(multiGet.Paths))
for i, p := range multiGet.Paths {
addressbookMultiget.Hrefs[i] = internal.Href{Path: p}
}
}
req, err := c.ic.NewXMLRequest("REPORT", path, &addressbookMultiget)
if err != nil {
return nil, err
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return decodeAddressList(ms)
}
func populateAddressObject(ao *AddressObject, h http.Header) error {
if loc := h.Get("Location"); loc != "" {
u, err := url.Parse(loc)
if err != nil {
return err
}
ao.Path = u.Path
}
if etag := h.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag)
if err != nil {
return err
}
ao.ETag = etag
}
if contentLength := h.Get("Content-Length"); contentLength != "" {
n, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return err
}
ao.ContentLength = n
}
if lastModified := h.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified)
if err != nil {
return err
}
ao.ModTime = t
}
return nil
}
func (c *Client) GetAddressObject(ctx context.Context, path string) (*AddressObject, error) {
req, err := c.ic.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", vcard.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer resp.Body.Close()
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return nil, err
}
if !strings.EqualFold(mediaType, vcard.MIMEType) {
return nil, fmt.Errorf("carddav: expected Content-Type %q, got %q", vcard.MIMEType, mediaType)
}
card, err := vcard.NewDecoder(resp.Body).Decode()
if err != nil {
return nil, err
}
ao := &AddressObject{
Path: resp.Request.URL.Path,
Card: card,
}
if err := populateAddressObject(ao, resp.Header); err != nil {
return nil, err
}
return ao, nil
}
func (c *Client) PutAddressObject(ctx context.Context, path string, card vcard.Card) (*AddressObject, error) {
// TODO: add support for If-None-Match and If-Match
// TODO: some servers want a Content-Length header, so we can't stream the
// request body here. See the Radicale issue:
// https://github.com/Kozea/Radicale/issues/1016
//pr, pw := io.Pipe()
//go func() {
// err := vcard.NewEncoder(pw).Encode(card)
// pw.CloseWithError(err)
//}()
var buf bytes.Buffer
if err := vcard.NewEncoder(&buf).Encode(card); err != nil {
return nil, err
}
req, err := c.ic.NewRequest(http.MethodPut, path, &buf)
if err != nil {
//pr.Close()
return nil, err
}
req.Header.Set("Content-Type", vcard.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
resp.Body.Close()
ao := &AddressObject{Path: path}
if err := populateAddressObject(ao, resp.Header); err != nil {
return nil, err
}
return ao, nil
}
// SyncCollection performs a collection synchronization operation on the
// specified resource, as defined in RFC 6578.
func (c *Client) SyncCollection(ctx context.Context, path string, query *SyncQuery) (*SyncResponse, error) {
var limit *internal.Limit
if query.Limit > 0 {
limit = &internal.Limit{NResults: uint(query.Limit)}
}
propReq, err := encodeAddressPropReq(&query.DataRequest)
if err != nil {
return nil, err
}
ms, err := c.ic.SyncCollection(ctx, path, query.SyncToken, internal.DepthOne, limit, propReq)
if err != nil {
return nil, err
}
ret := &SyncResponse{SyncToken: ms.SyncToken}
for _, resp := range ms.Responses {
p, err := resp.Path()
if err != nil {
if err, ok := err.(*internal.HTTPError); ok && err.Code == http.StatusNotFound {
ret.Deleted = append(ret.Deleted, p)
continue
}
return nil, err
}
if p == path || path == fmt.Sprintf("%s/", p) {
continue
}
var getLastMod internal.GetLastModified
if err := resp.DecodeProp(&getLastMod); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getETag internal.GetETag
if err := resp.DecodeProp(&getETag); err != nil && !internal.IsNotFound(err) {
return nil, err
}
o := AddressObject{
Path: p,
ModTime: time.Time(getLastMod.LastModified),
ETag: string(getETag.ETag),
}
ret.Updated = append(ret.Updated, o)
}
return ret, nil
}

View File

@ -1,221 +0,0 @@
package carddav
import (
"encoding/xml"
"fmt"
"github.com/emersion/go-webdav/internal"
)
const namespace = "urn:ietf:params:xml:ns:carddav"
var (
addressBookHomeSetName = xml.Name{namespace, "addressbook-home-set"}
addressBookName = xml.Name{namespace, "addressbook"}
addressBookDescriptionName = xml.Name{namespace, "addressbook-description"}
supportedAddressDataName = xml.Name{namespace, "supported-address-data"}
maxResourceSizeName = xml.Name{namespace, "max-resource-size"}
addressBookQueryName = xml.Name{namespace, "addressbook-query"}
addressBookMultigetName = xml.Name{namespace, "addressbook-multiget"}
addressDataName = xml.Name{namespace, "address-data"}
)
// https://tools.ietf.org/html/rfc6352#section-6.2.3
type addressbookHomeSet struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-home-set"`
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"`
}
// https://tools.ietf.org/html/rfc6352#section-6.2.2
type supportedAddressData struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav supported-address-data"`
Types []addressDataType `xml:"address-data-type"`
}
type addressDataType struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav address-data-type"`
ContentType string `xml:"content-type,attr"`
Version string `xml:"version,attr"`
}
// https://tools.ietf.org/html/rfc6352#section-6.2.3
type maxResourceSize struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav max-resource-size"`
Size int64 `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc6352#section-10.3
type addressbookQuery struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-query"`
Prop *internal.Prop `xml:"DAV: prop,omitempty"`
AllProp *struct{} `xml:"DAV: allprop,omitempty"`
PropName *struct{} `xml:"DAV: propname,omitempty"`
Filter filter `xml:"filter"`
Limit *limit `xml:"limit,omitempty"`
}
// https://tools.ietf.org/html/rfc6352#section-10.5
type filter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav filter"`
Test filterTest `xml:"test,attr,omitempty"`
Props []propFilter `xml:"prop-filter"`
}
type filterTest string
func (ft *filterTest) UnmarshalText(b []byte) error {
switch FilterTest(b) {
case FilterAnyOf, FilterAllOf:
*ft = filterTest(b)
return nil
default:
return fmt.Errorf("carddav: invalid filter test value: %q", string(b))
}
}
// https://tools.ietf.org/html/rfc6352#section-10.5.1
type propFilter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav prop-filter"`
Name string `xml:"name,attr"`
Test filterTest `xml:"test,attr,omitempty"`
IsNotDefined *struct{} `xml:"is-not-defined,omitempty"`
TextMatches []textMatch `xml:"text-match,omitempty"`
Params []paramFilter `xml:"param-filter,omitempty"`
}
// https://tools.ietf.org/html/rfc6352#section-10.5.4
type textMatch struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav text-match"`
Text string `xml:",chardata"`
Collation string `xml:"collation,attr,omitempty"`
NegateCondition negateCondition `xml:"negate-condition,attr,omitempty"`
MatchType matchType `xml:"match-type,attr,omitempty"`
}
type negateCondition bool
func (nc *negateCondition) UnmarshalText(b []byte) error {
switch s := string(b); s {
case "yes":
*nc = true
case "no":
*nc = false
default:
return fmt.Errorf("carddav: invalid negate-condition value: %q", s)
}
return nil
}
func (nc negateCondition) MarshalText() ([]byte, error) {
if nc {
return []byte("yes"), nil
}
return nil, nil
}
type matchType MatchType
func (mt *matchType) UnmarshalText(b []byte) error {
switch MatchType(b) {
case MatchEquals, MatchContains, MatchStartsWith, MatchEndsWith:
*mt = matchType(b)
return nil
default:
return fmt.Errorf("carddav: invalid match type value: %q", string(b))
}
}
// https://tools.ietf.org/html/rfc6352#section-10.5.2
type paramFilter struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav param-filter"`
Name string `xml:"name,attr"`
IsNotDefined *struct{} `xml:"is-not-defined"`
TextMatch *textMatch `xml:"text-match"`
}
// https://tools.ietf.org/html/rfc6352#section-10.6
type limit struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav limit"`
NResults uint `xml:"nresults"`
}
// https://tools.ietf.org/html/rfc6352#section-8.7
type addressbookMultiget struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-multiget"`
Hrefs []internal.Href `xml:"DAV: href"`
Prop *internal.Prop `xml:"DAV: prop,omitempty"`
AllProp *struct{} `xml:"DAV: allprop,omitempty"`
PropName *struct{} `xml:"DAV: propname,omitempty"`
}
func newProp(name string, noValue bool) *internal.RawXMLValue {
attrs := []xml.Attr{{Name: xml.Name{namespace, "name"}, Value: name}}
if noValue {
attrs = append(attrs, xml.Attr{Name: xml.Name{namespace, "novalue"}, Value: "yes"})
}
xmlName := xml.Name{namespace, "prop"}
return internal.NewRawXMLElement(xmlName, attrs, nil)
}
// https://tools.ietf.org/html/rfc6352#section-10.4
type addressDataReq struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav address-data"`
Props []prop `xml:"prop"`
Allprop *struct{} `xml:"allprop"`
}
// https://tools.ietf.org/html/rfc6352#section-10.4.2
type prop struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav prop"`
Name string `xml:"name,attr"`
// TODO: novalue
}
// https://tools.ietf.org/html/rfc6352#section-10.4
type addressDataResp struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav address-data"`
Data []byte `xml:",chardata"`
}
type reportReq struct {
Query *addressbookQuery
Multiget *addressbookMultiget
}
func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var v interface{}
switch start.Name {
case addressBookQueryName:
r.Query = &addressbookQuery{}
v = r.Query
case addressBookMultigetName:
r.Multiget = &addressbookMultiget{}
v = r.Multiget
default:
return fmt.Errorf("carddav: unsupported REPORT root %q %q", start.Name.Space, start.Name.Local)
}
return d.DecodeElement(v, &start)
}
type mkcolReq struct {
XMLName xml.Name `xml:"DAV: mkcol"`
ResourceType internal.ResourceType `xml:"set>prop>resourcetype"`
DisplayName string `xml:"set>prop>displayname"`
Description addressbookDescription `xml:"set>prop>addressbook-description"`
// TODO this could theoretically contain all addressbook properties?
}

408
carddav/fs.go Normal file
View File

@ -0,0 +1,408 @@
package carddav
import (
"bytes"
"encoding/xml"
"errors"
"os"
"strconv"
"strings"
"time"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"golang.org/x/net/context"
)
var (
errNotYetImplemented = errors.New("not yet implemented")
errUnsupported = errors.New("unsupported")
)
const nsDAV = "DAV:"
var (
resourcetype = xml.Name{Space: nsDAV, Local: "resourcetype"}
displayname = xml.Name{Space: nsDAV, Local: "displayname"}
getcontenttype = xml.Name{Space: nsDAV, Local: "getcontenttype"}
)
const nsCardDAV = "urn:ietf:params:xml:ns:carddav"
var (
addressBookDescription = xml.Name{Space: nsCardDAV, Local: "addressbook-description"}
addressBookSupportedAddressData = xml.Name{Space: nsCardDAV, Local: "supported-address-data"}
addressBookMaxResourceSize = xml.Name{Space: nsCardDAV, Local: "max-resource-size"}
addressBookHomeSet = xml.Name{Space: nsCardDAV, Local: "addressbook-home-set"}
)
func addressObjectName(ao AddressObject) string {
return ao.ID()
}
type fileInfo struct {
name string
size int64
mode os.FileMode
modTime time.Time
}
func addressObjectFileInfo(ao AddressObject) *fileInfo {
return &fileInfo{
name: addressObjectName(ao),
mode: os.ModePerm,
}
}
func (fi *fileInfo) Name() string {
return fi.name
}
func (fi *fileInfo) Size() int64 {
return fi.size
}
func (fi *fileInfo) Mode() os.FileMode {
return fi.mode
}
func (fi *fileInfo) ModTime() time.Time {
return fi.modTime
}
func (fi *fileInfo) IsDir() bool {
return fi.mode.IsDir()
}
func (fi *fileInfo) Sys() interface{} {
return nil
}
type file struct {
r *bytes.Reader
w *bytes.Buffer
fs *fileSystem
ao AddressObject
}
func (f *file) Close() error {
if f.w != nil {
defer func() {
f.w = nil
}()
card, err := vcard.NewDecoder(f.w).Decode()
if err != nil {
return err
}
if err := f.ao.SetCard(card); err != nil {
return err
}
}
f.r = nil
return nil
}
func (f *file) Read(b []byte) (int, error) {
if f.r == nil {
card, err := f.ao.Card()
if err != nil {
return 0, err
}
var b bytes.Buffer
if err := vcard.NewEncoder(&b).Encode(card); err != nil {
return 0, err
}
f.r = bytes.NewReader(b.Bytes())
}
return f.r.Read(b)
}
func (f *file) Write(b []byte) (int, error) {
if f.w == nil {
f.w = &bytes.Buffer{}
}
return f.w.Write(b)
}
func (f *file) Seek(offset int64, whence int) (int64, error) {
if f.r == nil {
if _, err := f.Read(nil); err != nil {
return 0, err
}
}
return f.r.Seek(offset, whence)
}
func (f *file) Readdir(count int) ([]os.FileInfo, error) {
return nil, errUnsupported
}
func (f *file) Stat() (os.FileInfo, error) {
info, err := f.ao.Stat()
if info != nil || err != nil {
return info, err
}
return addressObjectFileInfo(f.ao), nil
}
func (f *file) DeadProps() (map[xml.Name]webdav.Property, error) {
return map[xml.Name]webdav.Property{
getcontenttype: webdav.Property{
XMLName: getcontenttype,
InnerXML: []byte(vcard.MIMEType),
},
}, nil
}
func (f *file) Patch([]webdav.Proppatch) ([]webdav.Propstat, error) {
return nil, errUnsupported
}
type newFile struct {
buf bytes.Buffer
fs *fileSystem
ao AddressObject
}
func (f *newFile) Close() error {
if f.ao == nil {
defer f.buf.Reset()
card, err := vcard.NewDecoder(&f.buf).Decode()
if err != nil {
return err
}
ao, err := f.fs.ab.CreateAddressObject(card)
if err != nil {
return err
}
f.ao = ao
}
return nil
}
func (f *newFile) Read(b []byte) (int, error) {
return 0, errUnsupported
}
func (f *newFile) Write(b []byte) (int, error) {
// TODO: limit amount of data in f.buf
if f.ao != nil {
return 0, errUnsupported
}
return f.buf.Write(b)
}
func (f *newFile) Seek(offset int64, whence int) (int64, error) {
return 0, errUnsupported
}
func (f *newFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, errUnsupported
}
func (f *newFile) Stat() (os.FileInfo, error) {
// Only available after a successful call to Close
if f.ao == nil {
return nil, errUnsupported
}
info, err := f.ao.Stat()
if info != nil || err != nil {
return info, err
}
return addressObjectFileInfo(f.ao), nil
}
type dir struct {
fs *fileSystem
name string
files []os.FileInfo
n int
}
func (d *dir) Close() error {
return nil
}
func (d *dir) Read(b []byte) (int, error) {
return 0, errUnsupported
}
func (d *dir) Write(b []byte) (int, error) {
return 0, errUnsupported
}
func (d *dir) Seek(offset int64, whence int) (int64, error) {
return 0, errUnsupported
}
func (d *dir) Readdir(count int) ([]os.FileInfo, error) {
if d.files == nil {
aos, err := d.fs.ab.ListAddressObjects()
if err != nil {
return nil, err
}
d.files = make([]os.FileInfo, len(aos))
for i, ao := range aos {
d.files[i] = addressObjectFileInfo(ao)
}
}
if count == 0 {
count = len(d.files) - d.n
}
if d.n >= len(d.files) {
return nil, nil
}
from := d.n
d.n += count
if d.n > len(d.files) {
d.n = len(d.files)
}
return d.files[from:d.n], nil
}
func (d *dir) Stat() (os.FileInfo, error) {
return &fileInfo{
name: d.name,
mode: os.ModeDir | os.ModePerm,
}, nil
}
func (d *dir) DeadProps() (map[xml.Name]webdav.Property, error) {
info, err := d.fs.ab.Info()
if err != nil {
return nil, err
}
return map[xml.Name]webdav.Property{
resourcetype: webdav.Property{
XMLName: resourcetype,
InnerXML: []byte(`<collection xmlns="DAV:"/><addressbook xmlns="urn:ietf:params:xml:ns:carddav"/>`),
},
displayname: webdav.Property{
XMLName: displayname,
InnerXML: []byte(info.Name),
},
addressBookDescription: webdav.Property{
XMLName: addressBookDescription,
InnerXML: []byte(info.Description),
},
addressBookSupportedAddressData: webdav.Property{
XMLName: addressBookSupportedAddressData,
InnerXML: []byte(`<address-data-type xmlns="urn:ietf:params:xml:ns:carddav" content-type="text/vcard" version="3.0"/>` +
`<address-data-type xmlns="urn:ietf:params:xml:ns:carddav" content-type="text/vcard" version="4.0"/>`),
},
addressBookMaxResourceSize: webdav.Property{
XMLName: addressBookMaxResourceSize,
InnerXML: []byte(strconv.Itoa(info.MaxResourceSize)),
},
addressBookHomeSet: webdav.Property{
XMLName: addressBookHomeSet,
InnerXML: []byte(`<href xmlns="DAV:">/</href>`),
},
}, nil
}
func (d *dir) Patch([]webdav.Proppatch) ([]webdav.Propstat, error) {
return nil, errUnsupported
}
type fileSystem struct {
ab AddressBook
}
func (fs *fileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return errNotYetImplemented
}
func (fs *fileSystem) addressObjectID(name string) string {
return strings.TrimLeft(name, "/")
}
func (fs *fileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if name == "/" {
return &dir{
fs: fs,
name: name,
}, nil
}
id := fs.addressObjectID(name)
ao, err := fs.ab.GetAddressObject(id)
if err == ErrNotFound && flag & os.O_CREATE != 0 {
return &newFile{fs: fs}, nil
} else if err != nil {
return nil, err
}
f := &file{
fs: fs,
ao: ao,
}
if flag&os.O_RDONLY != 0 {
// This file will be read, cache its contents
if _, err := f.Read(nil); err != nil {
return f, err
}
}
return f, nil
}
func (fs *fileSystem) RemoveAll(ctx context.Context, name string) error {
if name == "/" {
return errUnsupported
}
id := fs.addressObjectID(name)
ao, err := fs.ab.GetAddressObject(id)
if err != nil {
return err
}
return ao.Remove()
}
func (fs *fileSystem) Rename(ctx context.Context, oldName, newName string) error {
return errNotYetImplemented
}
func (fs *fileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) {
if name == "/" {
return &fileInfo{
name: name,
mode: os.ModeDir | os.ModePerm,
}, nil
}
id := fs.addressObjectID(name)
ao, err := fs.ab.GetAddressObject(id)
if err != nil {
return nil, err
}
info, err := ao.Stat()
if info != nil || err != nil {
return info, err
}
return addressObjectFileInfo(ao), nil
}

View File

@ -1,172 +0,0 @@
package carddav
import (
"fmt"
"strings"
"github.com/emersion/go-vcard"
)
func filterProperties(req AddressDataRequest, ao AddressObject) AddressObject {
if req.AllProp || len(req.Props) == 0 {
return ao
}
if len(ao.Card) == 0 {
panic("request to process empty vCard")
}
result := AddressObject{
Path: ao.Path,
ModTime: ao.ModTime,
ETag: ao.ETag,
}
result.Card = make(vcard.Card)
// result would be invalid w/o version
result.Card[vcard.FieldVersion] = ao.Card[vcard.FieldVersion]
for _, prop := range req.Props {
value, ok := ao.Card[prop]
if ok {
result.Card[prop] = value
}
}
return result
}
// Filter returns the filtered list of address objects matching the provided query.
// A nil query will return the full list of address objects.
func Filter(query *AddressBookQuery, aos []AddressObject) ([]AddressObject, error) {
if query == nil {
// FIXME: should we always return a copy of the provided slice?
return aos, nil
}
n := query.Limit
if n <= 0 || n > len(aos) {
n = len(aos)
}
out := make([]AddressObject, 0, n)
for _, ao := range aos {
ok, err := Match(query, &ao)
if err != nil {
return nil, err
}
if !ok {
continue
}
out = append(out, filterProperties(query.DataRequest, ao))
if len(out) >= n {
break
}
}
return out, nil
}
// Match reports whether the provided AddressObject matches the query.
func Match(query *AddressBookQuery, ao *AddressObject) (matched bool, err error) {
if query == nil {
return true, nil
}
switch query.FilterTest {
default:
return false, fmt.Errorf("unknown query filter test %q", query.FilterTest)
case FilterAnyOf, "":
for _, prop := range query.PropFilters {
ok, err := matchPropFilter(prop, ao)
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, nil
case FilterAllOf:
for _, prop := range query.PropFilters {
ok, err := matchPropFilter(prop, ao)
if err != nil {
return false, err
}
if !ok {
return false, nil
}
}
return true, nil
}
}
func matchPropFilter(prop PropFilter, ao *AddressObject) (bool, error) {
// TODO: this only matches first field, there could be multiple
field := ao.Card.Get(prop.Name)
if field == nil {
return prop.IsNotDefined, nil
} else if prop.IsNotDefined {
return false, nil
}
// TODO: handle carddav.PropFilter.Params.
if len(prop.TextMatches) == 0 {
return true, nil
}
switch prop.Test {
default:
return false, fmt.Errorf("unknown property filter test %q", prop.Test)
case FilterAnyOf, "":
for _, txt := range prop.TextMatches {
ok, err := matchTextMatch(txt, field)
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, nil
case FilterAllOf:
for _, txt := range prop.TextMatches {
ok, err := matchTextMatch(txt, field)
if err != nil {
return false, err
}
if !ok {
return false, nil
}
}
return true, nil
}
}
func matchTextMatch(txt TextMatch, field *vcard.Field) (bool, error) {
// TODO: handle text-match collation attribute
var ok bool
switch txt.MatchType {
default:
return false, fmt.Errorf("unknown textmatch type %q", txt.MatchType)
case MatchEquals:
ok = txt.Text == field.Value
case MatchContains, "":
ok = strings.Contains(field.Value, txt.Text)
case MatchStartsWith:
ok = strings.HasPrefix(field.Value, txt.Text)
case MatchEndsWith:
ok = strings.HasSuffix(field.Value, txt.Text)
}
if txt.NegateCondition {
ok = !ok
}
return ok, nil
}

View File

@ -1,633 +0,0 @@
package carddav
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/emersion/go-vcard"
)
func TestFilter(t *testing.T) {
newAO := func(str string) AddressObject {
card, err := vcard.NewDecoder(strings.NewReader(str)).Decode()
if err != nil {
t.Fatal(err)
}
return AddressObject{
Card: card,
}
}
alice := newAO(`BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1
FN;PID=1.1:Alice Gopher
N:Gopher;Alice;;;
EMAIL;PID=1.1:alice@example.com
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0551
END:VCARD`)
bob := newAO(`BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b2
FN;PID=1.1:Bob Gopher
N:Gopher;Bob;;;
EMAIL;PID=1.1:bob@example.com
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0552
END:VCARD`)
carla := newAO(`BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b3
FN;PID=1.1:Carla Gopher
N:Gopher;Carla;;;
EMAIL;PID=1.1:carla@example.com
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0553
END:VCARD`)
carlaFiltered := newAO(`BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b3
EMAIL;PID=1.1:carla@example.com
END:VCARD`)
for _, tc := range []struct {
name string
query *AddressBookQuery
addrs []AddressObject
want []AddressObject
err error
}{
{
name: "nil-query",
query: nil,
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice, bob, carla},
},
{
name: "no-limit-query",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice, bob, carla},
},
{
name: "limit-1-query",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
Limit: 1,
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice},
},
{
name: "limit-4-query",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
Limit: 4,
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice, bob, carla},
},
{
name: "email-match",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "carla"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{carla},
},
{
name: "email-match-any",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{
{Text: "carla@example"},
{Text: "alice@example"},
},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice, carla},
},
{
name: "email-match-all",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
PropFilters: []PropFilter{{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{
{Text: ""},
},
}},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{alice, bob, carla},
},
{
name: "email-no-match",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
AllProp: true,
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.org"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{},
},
{
name: "email-match-filter-properties",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldVersion,
vcard.FieldUID,
vcard.FieldEmail,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "carla"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{carlaFiltered},
},
{
name: "email-match-filter-properties-always-returns-version",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldUID,
vcard.FieldEmail,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "carla"}},
},
},
},
addrs: []AddressObject{alice, bob, carla},
want: []AddressObject{carlaFiltered},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := Filter(tc.query, tc.addrs)
switch {
case err != nil && tc.err == nil:
t.Fatalf("unexpected error: %+v", err)
case err != nil && tc.err != nil:
if got, want := err.Error(), tc.err.Error(); got != want {
t.Fatalf("invalid error:\ngot= %q\nwant=%q", got, want)
}
case err == nil && tc.err != nil:
t.Fatalf("expected an error:\ngot= %+v\nwant=%+v", err, tc.err)
case err == nil && tc.err == nil:
if got, want := got, tc.want; !reflect.DeepEqual(got, want) {
t.Fatalf("invalid filter values:\ngot= %+v\nwant=%+v", got, want)
}
}
})
}
}
func TestMatch(t *testing.T) {
newAO := func(str string) AddressObject {
card, err := vcard.NewDecoder(strings.NewReader(str)).Decode()
if err != nil {
t.Fatal(err)
}
return AddressObject{
Card: card,
}
}
alice := newAO(`BEGIN:VCARD
VERSION:4.0
UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1
FN;PID=1.1:Alice Gopher
N:Gopher;Alice;;;
EMAIL;PID=1.1:alice@example.com
CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556
END:VCARD`)
for _, tc := range []struct {
name string
query *AddressBookQuery
addr AddressObject
want bool
err error
}{
{
name: "nil-query",
query: nil,
addr: alice,
want: true,
},
{
name: "match-email-contains",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-email-equals-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: "alice@example.com",
MatchType: MatchEquals,
}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-email-equals-not",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: "example.com",
MatchType: MatchEquals,
}},
},
},
},
addr: alice,
want: false,
},
{
name: "match-email-equals-ok-negate",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: "bob@example.com",
NegateCondition: true,
MatchType: MatchEquals,
}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-email-starts-with-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: "alice@",
MatchType: MatchStartsWith,
}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-email-ends-with-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: "com",
MatchType: MatchEndsWith,
}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-email-ends-with-not",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{
Text: ".org",
MatchType: MatchEndsWith,
}},
},
},
},
addr: alice,
want: false,
},
{
name: "match-name-contains-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldName,
TextMatches: []TextMatch{{
Text: "Alice",
MatchType: MatchContains,
}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-name-contains-all-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldName,
Test: FilterAllOf,
TextMatches: []TextMatch{
{
Text: "Alice",
MatchType: MatchContains,
},
{
Text: "Gopher",
MatchType: MatchContains,
},
},
},
},
},
addr: alice,
want: true,
},
{
name: "match-name-contains-all-prop-not",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
FilterTest: FilterAllOf,
PropFilters: []PropFilter{
{
Name: vcard.FieldName,
TextMatches: []TextMatch{{
Text: "Alice",
MatchType: MatchContains,
}},
},
{
Name: vcard.FieldName,
TextMatches: []TextMatch{{
Text: "GopherXXX",
MatchType: MatchContains,
}},
},
},
},
addr: alice,
want: false,
},
{
name: "match-name-contains-all-text-match-not",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldName,
Test: FilterAllOf,
TextMatches: []TextMatch{
{
Text: "Alice",
MatchType: MatchContains,
},
{
Text: "GopherXXX",
MatchType: MatchContains,
},
},
},
},
},
addr: alice,
want: false,
},
{
name: "missing-prop-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
"XXX-not-THERE", // but AllProp is false.
},
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addr: alice,
want: true,
},
{
name: "match-all-prop-ok",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
AllProp: true,
},
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addr: alice,
want: true,
},
{
name: "invalid-query-filter",
query: &AddressBookQuery{
DataRequest: AddressDataRequest{
Props: []string{
vcard.FieldFormattedName,
vcard.FieldEmail,
vcard.FieldUID,
},
},
FilterTest: "XXX-invalid-filter",
PropFilters: []PropFilter{
{
Name: vcard.FieldEmail,
TextMatches: []TextMatch{{Text: "example.com"}},
},
},
},
addr: alice,
err: fmt.Errorf("unknown query filter test \"XXX-invalid-filter\""),
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := Match(tc.query, &tc.addr)
switch {
case err != nil && tc.err == nil:
t.Fatalf("unexpected error: %+v", err)
case err != nil && tc.err != nil:
if got, want := err.Error(), tc.err.Error(); got != want {
t.Fatalf("invalid error:\ngot= %q\nwant=%q", got, want)
}
case err == nil && tc.err != nil:
t.Fatalf("expected an error:\ngot= %+v\nwant=%+v", err, tc.err)
case err == nil && tc.err == nil:
if got, want := got, tc.want; got != want {
t.Fatalf("invalid match value: got=%v, want=%v", got, want)
}
}
})
}
}

View File

@ -1,758 +0,0 @@
package carddav
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"mime"
"net/http"
"path"
"strconv"
"strings"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/internal"
)
type PutAddressObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite
// an existing resource.
IfNoneMatch webdav.ConditionalMatch
// IfMatch provides the ETag of the resource that the client intends
// to overwrite, can be ""
IfMatch webdav.ConditionalMatch
}
// Backend is a CardDAV server backend.
type Backend interface {
AddressBookHomeSetPath(ctx context.Context) (string, error)
ListAddressBooks(ctx context.Context) ([]AddressBook, error)
GetAddressBook(ctx context.Context, path string) (*AddressBook, error)
CreateAddressBook(ctx context.Context, addressBook *AddressBook) error
DeleteAddressBook(ctx context.Context, path string) error
GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error)
ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error)
QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error)
PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, 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
Prefix string
}
// 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{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
hh := internal.Handler{Backend: &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, 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(r *http.Request, 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{Code: http.StatusBadRequest, Err: 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(r.Context(), r.URL.Path, &q)
if err != nil {
return err
}
var resps []internal.Response
for _, ao := range aos {
b := backend{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
propfind := internal.PropFind{
Prop: query.Prop,
AllProp: query.AllProp,
PropName: query.PropName,
}
resp, err := b.propFindAddressObject(r.Context(), &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{
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
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
Prefix string
}
type resourceType int
const (
resourceTypeRoot resourceType = iota
resourceTypeUserPrincipal
resourceTypeAddressBookHomeSet
resourceTypeAddressBook
resourceTypeAddressObject
)
func (b *backend) resourceTypeAtPath(reqPath string) resourceType {
p := path.Clean(reqPath)
p = strings.TrimPrefix(p, b.Prefix)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if p == "/" {
return resourceTypeRoot
}
return resourceType(len(strings.Split(p, "/")) - 1)
}
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
caps = []string{"addressbook"}
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeAddressObject {
// 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.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(ao.ContentLength, 10))
}
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) {
resType := b.resourceTypeAtPath(r.URL.Path)
var dataReq AddressDataRequest
var resps []internal.Response
switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
return nil, err
}
if r.URL.Path == principalPath {
resp, err := b.propFindUserPrincipal(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resp, err := b.propFindHomeSet(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth == internal.DepthInfinity {
resps_, err := b.propFindAllAddressBooks(r.Context(), propfind, true)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
}
case resourceTypeAddressBookHomeSet:
homeSetPath, err := b.Backend.AddressBookHomeSetPath(r.Context())
if err != nil {
return nil, err
}
if r.URL.Path == homeSetPath {
resp, err := b.propFindHomeSet(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
recurse := depth == internal.DepthInfinity
resps_, err := b.propFindAllAddressBooks(r.Context(), propfind, recurse)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
case resourceTypeAddressBook:
ab, err := b.Backend.GetAddressBook(r.Context(), r.URL.Path)
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 {
resps_, err := b.propFindAllAddressObjects(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
case resourceTypeAddressObject:
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) propFindRoot(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(ctx)
if err != nil {
return nil, err
}
return &addressbookHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(ctx)
if err != nil {
return nil, err
}
// TODO anything else to return here?
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(homeSetPath, 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: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, addressBookName)),
supportedAddressDataName: internal.PropFindValue(&supportedAddressData{
Types: []addressDataType{
{ContentType: vcard.MIMEType, Version: "3.0"},
{ContentType: vcard.MIMEType, Version: "4.0"},
},
}),
}
if ab.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: ab.Name,
})
}
if ab.Description != "" {
props[addressBookDescriptionName] = internal.PropFindValue(&addressbookDescription{
Description: ab.Description,
})
}
if ab.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{
Size: ab.MaxResourceSize,
})
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
}
return internal.NewPropFindResponse(ab.Path, propfind, props)
}
func (b *backend) propFindAllAddressBooks(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) {
abs, err := b.Backend.ListAddressBooks(ctx)
if err != nil {
return nil, err
}
var resps []internal.Response
for _, ab := range abs {
resp, err := b.propFindAddressBook(ctx, propfind, &ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if recurse {
resps_, err := b.propFindAllAddressObjects(ctx, propfind, &ab)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
return resps, nil
}
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: internal.PropFindValue(&internal.GetContentType{
Type: vcard.MIMEType,
}),
// 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.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: ao.ContentLength,
})
}
if !ao.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(ao.ModTime),
})
}
if ao.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(ao.ETag),
})
}
return internal.NewPropFindResponse(ao.Path, propfind, props)
}
func (b *backend) propFindAllAddressObjects(ctx context.Context, propfind *internal.PropFind, ab *AddressBook) ([]internal.Response, error) {
var dataReq AddressDataRequest
aos, err := b.Backend.ListAddressObjects(ctx, ab.Path, &dataReq)
if err != nil {
return nil, err
}
var resps []internal.Response
for _, ao := range aos {
resp, err := b.propFindAddressObject(ctx, propfind, &ao)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
}
return resps, nil
}
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(w http.ResponseWriter, r *http.Request) error {
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
opts := PutAddressObjectOptions{
IfNoneMatch: ifNoneMatch,
IfMatch: ifMatch,
}
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err)
}
if t != vcard.MIMEType {
// TODO: send CARDDAV:supported-address-data error
return 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 internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
}
// TODO: add support for the CARDDAV:no-uid-conflict error
ao, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
if err != nil {
return err
}
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 ao.Path != "" {
w.Header().Set("Location", ao.Path)
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
return nil
}
func (b *backend) Delete(r *http.Request) error {
switch b.resourceTypeAtPath(r.URL.Path) {
case resourceTypeAddressBook:
return b.Backend.DeleteAddressBook(r.Context(), r.URL.Path)
case resourceTypeAddressObject:
return b.Backend.DeleteAddressObject(r.Context(), r.URL.Path)
}
return internal.HTTPErrorf(http.StatusForbidden, "carddav: cannot delete resource at given location")
}
func (b *backend) Mkcol(r *http.Request) error {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeAddressBook {
return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation not allowed at given location")
}
ab := AddressBook{
Path: r.URL.Path,
}
if !internal.IsRequestBodyEmpty(r) {
var m mkcolReq
if err := internal.DecodeXMLRequest(r, &m); err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: error parsing mkcol request: %s", err.Error())
}
if !m.ResourceType.Is(internal.CollectionName) || !m.ResourceType.Is(addressBookName) {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
ab.Name = m.DisplayName
ab.Description = m.Description.Description
// TODO ...
}
return b.Backend.CreateAddressBook(r.Context(), &ab)
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "carddav: Copy not implemented")
}
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "carddav: Move not implemented")
}
// PreconditionType as defined in 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{Space: "urn:ietf:params:xml:ns:carddav", Local: string(err)}
elem := internal.NewRawXMLElement(name, nil, nil)
return &internal.HTTPError{
Code: 409,
Err: &internal.Error{
Raw: []internal.RawXMLValue{*elem},
},
}
}

24
carddav/xml.go Normal file
View File

@ -0,0 +1,24 @@
package carddav
import (
"encoding/xml"
"github.com/emersion/go-webdav"
)
// https://tools.ietf.org/html/rfc6352#section-10.7
type addressbookMultiget struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-multiget"`
Allprop *struct{} `xml:"DAV: allprop"`
Propname *struct{} `xml:"DAV: propname"`
Prop webdav.PropfindProps `xml:"DAV: prop"`
Href []string `xml:"DAV: href"`
}
// TODO
type addressData struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav address-data"`
ContentType string `xml:"content-type,attr"`
Version string `xml:"version,attr"`
Prop []string `xml:"prop>name,attr"`
}

301
client.go
View File

@ -1,301 +0,0 @@
package webdav
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/emersion/go-webdav/internal"
)
// HTTPClient performs HTTP requests. It's implemented by *http.Client.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type basicAuthHTTPClient struct {
c HTTPClient
username, password string
}
func (c *basicAuthHTTPClient) Do(req *http.Request) (*http.Response, error) {
req.SetBasicAuth(c.username, c.password)
return c.c.Do(req)
}
// HTTPClientWithBasicAuth returns an HTTP client that adds basic
// authentication to all outgoing requests. If c is nil, http.DefaultClient is
// used.
func HTTPClientWithBasicAuth(c HTTPClient, username, password string) HTTPClient {
if c == nil {
c = http.DefaultClient
}
return &basicAuthHTTPClient{c, username, password}
}
// Client provides access to a remote WebDAV filesystem.
type Client struct {
ic *internal.Client
}
// NewClient creates a new WebDAV client.
//
// If the HTTPClient is nil, http.DefaultClient is used.
//
// To use HTTP basic authentication, HTTPClientWithBasicAuth can be used.
func NewClient(c HTTPClient, endpoint string) (*Client, error) {
ic, err := internal.NewClient(c, endpoint)
if err != nil {
return nil, err
}
return &Client{ic}, nil
}
// FindCurrentUserPrincipal finds the current user's principal path.
func (c *Client) FindCurrentUserPrincipal(ctx context.Context) (string, error) {
propfind := internal.NewPropNamePropFind(internal.CurrentUserPrincipalName)
// TODO: consider retrying on the root URI "/" if this fails, as suggested
// by the RFC?
resp, err := c.ic.PropFindFlat(ctx, "", propfind)
if err != nil {
return "", err
}
var prop internal.CurrentUserPrincipal
if err := resp.DecodeProp(&prop); err != nil {
return "", err
}
if prop.Unauthenticated != nil {
return "", fmt.Errorf("webdav: unauthenticated")
}
return prop.Href.Path, nil
}
var fileInfoPropFind = internal.NewPropNamePropFind(
internal.ResourceTypeName,
internal.GetContentLengthName,
internal.GetLastModifiedName,
internal.GetContentTypeName,
internal.GetETagName,
)
func fileInfoFromResponse(resp *internal.Response) (*FileInfo, error) {
path, err := resp.Path()
if err != nil {
return nil, err
}
fi := &FileInfo{Path: path}
var resType internal.ResourceType
if err := resp.DecodeProp(&resType); err != nil {
return nil, err
}
if resType.Is(internal.CollectionName) {
fi.IsDir = true
} else {
var getLen internal.GetContentLength
if err := resp.DecodeProp(&getLen); err != nil {
return nil, err
}
var getType internal.GetContentType
if err := resp.DecodeProp(&getType); err != nil && !internal.IsNotFound(err) {
return nil, err
}
var getETag internal.GetETag
if err := resp.DecodeProp(&getETag); err != nil && !internal.IsNotFound(err) {
return nil, err
}
fi.Size = getLen.Length
fi.MIMEType = getType.Type
fi.ETag = string(getETag.ETag)
}
var getMod internal.GetLastModified
if err := resp.DecodeProp(&getMod); err != nil && !internal.IsNotFound(err) {
return nil, err
}
fi.ModTime = time.Time(getMod.LastModified)
return fi, nil
}
// Stat fetches a FileInfo for a single file.
func (c *Client) Stat(ctx context.Context, name string) (*FileInfo, error) {
resp, err := c.ic.PropFindFlat(ctx, name, fileInfoPropFind)
if err != nil {
return nil, err
}
return fileInfoFromResponse(resp)
}
// Open fetches a file's contents.
func (c *Client) Open(ctx context.Context, name string) (io.ReadCloser, error) {
req, err := c.ic.NewRequest(http.MethodGet, name, nil)
if err != nil {
return nil, err
}
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
return resp.Body, nil
}
// ReadDir lists files in a directory.
func (c *Client) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) {
depth := internal.DepthOne
if recursive {
depth = internal.DepthInfinity
}
ms, err := c.ic.PropFind(ctx, name, depth, fileInfoPropFind)
if err != nil {
return nil, err
}
l := make([]FileInfo, 0, len(ms.Responses))
for _, resp := range ms.Responses {
fi, err := fileInfoFromResponse(&resp)
if err != nil {
return l, err
}
l = append(l, *fi)
}
return l, nil
}
type fileWriter struct {
pw *io.PipeWriter
done <-chan error
}
func (fw *fileWriter) Write(b []byte) (int, error) {
return fw.pw.Write(b)
}
func (fw *fileWriter) Close() error {
if err := fw.pw.Close(); err != nil {
return err
}
return <-fw.done
}
// Create writes a file's contents.
func (c *Client) Create(ctx context.Context, name string) (io.WriteCloser, error) {
pr, pw := io.Pipe()
req, err := c.ic.NewRequest(http.MethodPut, name, pr)
if err != nil {
pw.Close()
return nil, err
}
done := make(chan error, 1)
go func() {
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
done <- err
return
}
resp.Body.Close()
done <- nil
}()
return &fileWriter{pw, done}, nil
}
// RemoveAll deletes a file. If the file is a directory, all of its descendants
// are recursively deleted as well.
func (c *Client) RemoveAll(ctx context.Context, name string) error {
req, err := c.ic.NewRequest(http.MethodDelete, name, nil)
if err != nil {
return err
}
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// Mkdir creates a new directory.
func (c *Client) Mkdir(ctx context.Context, name string) error {
req, err := c.ic.NewRequest("MKCOL", name, nil)
if err != nil {
return err
}
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// Copy copies a file.
//
// By default, if the file is a directory, all descendants are recursively
// copied as well.
func (c *Client) Copy(ctx context.Context, name, dest string, options *CopyOptions) error {
if options == nil {
options = new(CopyOptions)
}
req, err := c.ic.NewRequest("COPY", name, nil)
if err != nil {
return err
}
depth := internal.DepthInfinity
if options.NoRecursive {
depth = internal.DepthZero
}
req.Header.Set("Destination", c.ic.ResolveHref(dest).String())
req.Header.Set("Overwrite", internal.FormatOverwrite(!options.NoOverwrite))
req.Header.Set("Depth", depth.String())
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// Move moves a file.
func (c *Client) Move(ctx context.Context, name, dest string, options *MoveOptions) error {
if options == nil {
options = new(MoveOptions)
}
req, err := c.ic.NewRequest("MOVE", name, nil)
if err != nil {
return err
}
req.Header.Set("Destination", c.ic.ResolveHref(dest).String())
req.Header.Set("Overwrite", internal.FormatOverwrite(!options.NoOverwrite))
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return err
}
resp.Body.Close()
return nil
}

View File

@ -1,32 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"github.com/emersion/go-webdav"
)
func main() {
var addr string
flag.StringVar(&addr, "addr", ":8080", "listening address")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [options...] [directory]\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
path := flag.Arg(0)
if path == "" {
path = "."
}
handler := webdav.Handler{
FileSystem: webdav.LocalFileSystem(path),
}
log.Printf("WebDAV server listening on %v", addr)
log.Fatal(http.ListenAndServe(addr, &handler))
}

View File

@ -1,32 +0,0 @@
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"`
}

796
file.go Normal file
View File

@ -0,0 +1,796 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"encoding/xml"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/net/context"
)
// slashClean is equivalent to but slightly more efficient than
// path.Clean("/" + name).
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
return path.Clean(name)
}
// A FileSystem implements access to a collection of named files. The elements
// in a file path are separated by slash ('/', U+002F) characters, regardless
// of host operating system convention.
//
// Each method has the same semantics as the os package's function of the same
// name.
//
// Note that the os.Rename documentation says that "OS-specific restrictions
// might apply". In particular, whether or not renaming a file or directory
// overwriting another existing file or directory is an error is OS-dependent.
type FileSystem interface {
Mkdir(ctx context.Context, name string, perm os.FileMode) error
OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error)
RemoveAll(ctx context.Context, name string) error
Rename(ctx context.Context, oldName, newName string) error
Stat(ctx context.Context, name string) (os.FileInfo, error)
}
// A File is returned by a FileSystem's OpenFile method and can be served by a
// Handler.
//
// A File may optionally implement the DeadPropsHolder interface, if it can
// load and save dead properties.
type File interface {
http.File
io.Writer
}
// A Dir implements FileSystem using the native file system restricted to a
// specific directory tree.
//
// While the FileSystem.OpenFile method takes '/'-separated paths, a Dir's
// string value is a filename on the native file system, not a URL, so it is
// separated by filepath.Separator, which isn't necessarily '/'.
//
// An empty Dir is treated as ".".
type Dir string
func (d Dir) resolve(name string) string {
// This implementation is based on Dir.Open's code in the standard net/http package.
if filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0 ||
strings.Contains(name, "\x00") {
return ""
}
dir := string(d)
if dir == "" {
dir = "."
}
return filepath.Join(dir, filepath.FromSlash(slashClean(name)))
}
func (d Dir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
if name = d.resolve(name); name == "" {
return os.ErrNotExist
}
return os.Mkdir(name, perm)
}
func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
if name = d.resolve(name); name == "" {
return nil, os.ErrNotExist
}
f, err := os.OpenFile(name, flag, perm)
if err != nil {
return nil, err
}
return f, nil
}
func (d Dir) RemoveAll(ctx context.Context, name string) error {
if name = d.resolve(name); name == "" {
return os.ErrNotExist
}
if name == filepath.Clean(string(d)) {
// Prohibit removing the virtual root directory.
return os.ErrInvalid
}
return os.RemoveAll(name)
}
func (d Dir) Rename(ctx context.Context, oldName, newName string) error {
if oldName = d.resolve(oldName); oldName == "" {
return os.ErrNotExist
}
if newName = d.resolve(newName); newName == "" {
return os.ErrNotExist
}
if root := filepath.Clean(string(d)); root == oldName || root == newName {
// Prohibit renaming from or to the virtual root directory.
return os.ErrInvalid
}
return os.Rename(oldName, newName)
}
func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
if name = d.resolve(name); name == "" {
return nil, os.ErrNotExist
}
return os.Stat(name)
}
// NewMemFS returns a new in-memory FileSystem implementation.
func NewMemFS() FileSystem {
return &memFS{
root: memFSNode{
children: make(map[string]*memFSNode),
mode: 0660 | os.ModeDir,
modTime: time.Now(),
},
}
}
// A memFS implements FileSystem, storing all metadata and actual file data
// in-memory. No limits on filesystem size are used, so it is not recommended
// this be used where the clients are untrusted.
//
// Concurrent access is permitted. The tree structure is protected by a mutex,
// and each node's contents and metadata are protected by a per-node mutex.
//
// TODO: Enforce file permissions.
type memFS struct {
mu sync.Mutex
root memFSNode
}
// TODO: clean up and rationalize the walk/find code.
// walk walks the directory tree for the fullname, calling f at each step. If f
// returns an error, the walk will be aborted and return that same error.
//
// dir is the directory at that step, frag is the name fragment, and final is
// whether it is the final step. For example, walking "/foo/bar/x" will result
// in 3 calls to f:
// - "/", "foo", false
// - "/foo/", "bar", false
// - "/foo/bar/", "x", true
// The frag argument will be empty only if dir is the root node and the walk
// ends at that root node.
func (fs *memFS) walk(op, fullname string, f func(dir *memFSNode, frag string, final bool) error) error {
original := fullname
fullname = slashClean(fullname)
// Strip any leading "/"s to make fullname a relative path, as the walk
// starts at fs.root.
if fullname[0] == '/' {
fullname = fullname[1:]
}
dir := &fs.root
for {
frag, remaining := fullname, ""
i := strings.IndexRune(fullname, '/')
final := i < 0
if !final {
frag, remaining = fullname[:i], fullname[i+1:]
}
if frag == "" && dir != &fs.root {
panic("webdav: empty path fragment for a clean path")
}
if err := f(dir, frag, final); err != nil {
return &os.PathError{
Op: op,
Path: original,
Err: err,
}
}
if final {
break
}
child := dir.children[frag]
if child == nil {
return &os.PathError{
Op: op,
Path: original,
Err: os.ErrNotExist,
}
}
if !child.mode.IsDir() {
return &os.PathError{
Op: op,
Path: original,
Err: os.ErrInvalid,
}
}
dir, fullname = child, remaining
}
return nil
}
// find returns the parent of the named node and the relative name fragment
// from the parent to the child. For example, if finding "/foo/bar/baz" then
// parent will be the node for "/foo/bar" and frag will be "baz".
//
// If the fullname names the root node, then parent, frag and err will be zero.
//
// find returns an error if the parent does not already exist or the parent
// isn't a directory, but it will not return an error per se if the child does
// not already exist. The error returned is either nil or an *os.PathError
// whose Op is op.
func (fs *memFS) find(op, fullname string) (parent *memFSNode, frag string, err error) {
err = fs.walk(op, fullname, func(parent0 *memFSNode, frag0 string, final bool) error {
if !final {
return nil
}
if frag0 != "" {
parent, frag = parent0, frag0
}
return nil
})
return parent, frag, err
}
func (fs *memFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, frag, err := fs.find("mkdir", name)
if err != nil {
return err
}
if dir == nil {
// We can't create the root.
return os.ErrInvalid
}
if _, ok := dir.children[frag]; ok {
return os.ErrExist
}
dir.children[frag] = &memFSNode{
children: make(map[string]*memFSNode),
mode: perm.Perm() | os.ModeDir,
modTime: time.Now(),
}
return nil
}
func (fs *memFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, frag, err := fs.find("open", name)
if err != nil {
return nil, err
}
var n *memFSNode
if dir == nil {
// We're opening the root.
if flag&(os.O_WRONLY|os.O_RDWR) != 0 {
return nil, os.ErrPermission
}
n, frag = &fs.root, "/"
} else {
n = dir.children[frag]
if flag&(os.O_SYNC|os.O_APPEND) != 0 {
// memFile doesn't support these flags yet.
return nil, os.ErrInvalid
}
if flag&os.O_CREATE != 0 {
if flag&os.O_EXCL != 0 && n != nil {
return nil, os.ErrExist
}
if n == nil {
n = &memFSNode{
mode: perm.Perm(),
}
dir.children[frag] = n
}
}
if n == nil {
return nil, os.ErrNotExist
}
if flag&(os.O_WRONLY|os.O_RDWR) != 0 && flag&os.O_TRUNC != 0 {
n.mu.Lock()
n.data = nil
n.mu.Unlock()
}
}
children := make([]os.FileInfo, 0, len(n.children))
for cName, c := range n.children {
children = append(children, c.stat(cName))
}
return &memFile{
n: n,
nameSnapshot: frag,
childrenSnapshot: children,
}, nil
}
func (fs *memFS) RemoveAll(ctx context.Context, name string) error {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, frag, err := fs.find("remove", name)
if err != nil {
return err
}
if dir == nil {
// We can't remove the root.
return os.ErrInvalid
}
delete(dir.children, frag)
return nil
}
func (fs *memFS) Rename(ctx context.Context, oldName, newName string) error {
fs.mu.Lock()
defer fs.mu.Unlock()
oldName = slashClean(oldName)
newName = slashClean(newName)
if oldName == newName {
return nil
}
if strings.HasPrefix(newName, oldName+"/") {
// We can't rename oldName to be a sub-directory of itself.
return os.ErrInvalid
}
oDir, oFrag, err := fs.find("rename", oldName)
if err != nil {
return err
}
if oDir == nil {
// We can't rename from the root.
return os.ErrInvalid
}
nDir, nFrag, err := fs.find("rename", newName)
if err != nil {
return err
}
if nDir == nil {
// We can't rename to the root.
return os.ErrInvalid
}
oNode, ok := oDir.children[oFrag]
if !ok {
return os.ErrNotExist
}
if oNode.children != nil {
if nNode, ok := nDir.children[nFrag]; ok {
if nNode.children == nil {
return errNotADirectory
}
if len(nNode.children) != 0 {
return errDirectoryNotEmpty
}
}
}
delete(oDir.children, oFrag)
nDir.children[nFrag] = oNode
return nil
}
func (fs *memFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
fs.mu.Lock()
defer fs.mu.Unlock()
dir, frag, err := fs.find("stat", name)
if err != nil {
return nil, err
}
if dir == nil {
// We're stat'ting the root.
return fs.root.stat("/"), nil
}
if n, ok := dir.children[frag]; ok {
return n.stat(path.Base(name)), nil
}
return nil, os.ErrNotExist
}
// A memFSNode represents a single entry in the in-memory filesystem and also
// implements os.FileInfo.
type memFSNode struct {
// children is protected by memFS.mu.
children map[string]*memFSNode
mu sync.Mutex
data []byte
mode os.FileMode
modTime time.Time
deadProps map[xml.Name]Property
}
func (n *memFSNode) stat(name string) *memFileInfo {
n.mu.Lock()
defer n.mu.Unlock()
return &memFileInfo{
name: name,
size: int64(len(n.data)),
mode: n.mode,
modTime: n.modTime,
}
}
func (n *memFSNode) DeadProps() (map[xml.Name]Property, error) {
n.mu.Lock()
defer n.mu.Unlock()
if len(n.deadProps) == 0 {
return nil, nil
}
ret := make(map[xml.Name]Property, len(n.deadProps))
for k, v := range n.deadProps {
ret[k] = v
}
return ret, nil
}
func (n *memFSNode) Patch(patches []Proppatch) ([]Propstat, error) {
n.mu.Lock()
defer n.mu.Unlock()
pstat := Propstat{Status: http.StatusOK}
for _, patch := range patches {
for _, p := range patch.Props {
pstat.Props = append(pstat.Props, Property{XMLName: p.XMLName})
if patch.Remove {
delete(n.deadProps, p.XMLName)
continue
}
if n.deadProps == nil {
n.deadProps = map[xml.Name]Property{}
}
n.deadProps[p.XMLName] = p
}
}
return []Propstat{pstat}, nil
}
type memFileInfo struct {
name string
size int64
mode os.FileMode
modTime time.Time
}
func (f *memFileInfo) Name() string { return f.name }
func (f *memFileInfo) Size() int64 { return f.size }
func (f *memFileInfo) Mode() os.FileMode { return f.mode }
func (f *memFileInfo) ModTime() time.Time { return f.modTime }
func (f *memFileInfo) IsDir() bool { return f.mode.IsDir() }
func (f *memFileInfo) Sys() interface{} { return nil }
// A memFile is a File implementation for a memFSNode. It is a per-file (not
// per-node) read/write position, and a snapshot of the memFS' tree structure
// (a node's name and children) for that node.
type memFile struct {
n *memFSNode
nameSnapshot string
childrenSnapshot []os.FileInfo
// pos is protected by n.mu.
pos int
}
// A *memFile implements the optional DeadPropsHolder interface.
var _ DeadPropsHolder = (*memFile)(nil)
func (f *memFile) DeadProps() (map[xml.Name]Property, error) { return f.n.DeadProps() }
func (f *memFile) Patch(patches []Proppatch) ([]Propstat, error) { return f.n.Patch(patches) }
func (f *memFile) Close() error {
return nil
}
func (f *memFile) Read(p []byte) (int, error) {
f.n.mu.Lock()
defer f.n.mu.Unlock()
if f.n.mode.IsDir() {
return 0, os.ErrInvalid
}
if f.pos >= len(f.n.data) {
return 0, io.EOF
}
n := copy(p, f.n.data[f.pos:])
f.pos += n
return n, nil
}
func (f *memFile) Readdir(count int) ([]os.FileInfo, error) {
f.n.mu.Lock()
defer f.n.mu.Unlock()
if !f.n.mode.IsDir() {
return nil, os.ErrInvalid
}
old := f.pos
if old >= len(f.childrenSnapshot) {
// The os.File Readdir docs say that at the end of a directory,
// the error is io.EOF if count > 0 and nil if count <= 0.
if count > 0 {
return nil, io.EOF
}
return nil, nil
}
if count > 0 {
f.pos += count
if f.pos > len(f.childrenSnapshot) {
f.pos = len(f.childrenSnapshot)
}
} else {
f.pos = len(f.childrenSnapshot)
old = 0
}
return f.childrenSnapshot[old:f.pos], nil
}
func (f *memFile) Seek(offset int64, whence int) (int64, error) {
f.n.mu.Lock()
defer f.n.mu.Unlock()
npos := f.pos
// TODO: How to handle offsets greater than the size of system int?
switch whence {
case os.SEEK_SET:
npos = int(offset)
case os.SEEK_CUR:
npos += int(offset)
case os.SEEK_END:
npos = len(f.n.data) + int(offset)
default:
npos = -1
}
if npos < 0 {
return 0, os.ErrInvalid
}
f.pos = npos
return int64(f.pos), nil
}
func (f *memFile) Stat() (os.FileInfo, error) {
return f.n.stat(f.nameSnapshot), nil
}
func (f *memFile) Write(p []byte) (int, error) {
lenp := len(p)
f.n.mu.Lock()
defer f.n.mu.Unlock()
if f.n.mode.IsDir() {
return 0, os.ErrInvalid
}
if f.pos < len(f.n.data) {
n := copy(f.n.data[f.pos:], p)
f.pos += n
p = p[n:]
} else if f.pos > len(f.n.data) {
// Write permits the creation of holes, if we've seek'ed past the
// existing end of file.
if f.pos <= cap(f.n.data) {
oldLen := len(f.n.data)
f.n.data = f.n.data[:f.pos]
hole := f.n.data[oldLen:]
for i := range hole {
hole[i] = 0
}
} else {
d := make([]byte, f.pos, f.pos+len(p))
copy(d, f.n.data)
f.n.data = d
}
}
if len(p) > 0 {
// We should only get here if f.pos == len(f.n.data).
f.n.data = append(f.n.data, p...)
f.pos = len(f.n.data)
}
f.n.modTime = time.Now()
return lenp, nil
}
// moveFiles moves files and/or directories from src to dst.
//
// See section 9.9.4 for when various HTTP status codes apply.
func moveFiles(ctx context.Context, fs FileSystem, src, dst string, overwrite bool) (status int, err error) {
created := false
if _, err := fs.Stat(ctx, dst); err != nil {
if !os.IsNotExist(err) {
return http.StatusForbidden, err
}
created = true
} else if overwrite {
// Section 9.9.3 says that "If a resource exists at the destination
// and the Overwrite header is "T", then prior to performing the move,
// the server must perform a DELETE with "Depth: infinity" on the
// destination resource.
if err := fs.RemoveAll(ctx, dst); err != nil {
return http.StatusForbidden, err
}
} else {
return http.StatusPreconditionFailed, os.ErrExist
}
if err := fs.Rename(ctx, src, dst); err != nil {
return http.StatusForbidden, err
}
if created {
return http.StatusCreated, nil
}
return http.StatusNoContent, nil
}
func copyProps(dst, src File) error {
d, ok := dst.(DeadPropsHolder)
if !ok {
return nil
}
s, ok := src.(DeadPropsHolder)
if !ok {
return nil
}
m, err := s.DeadProps()
if err != nil {
return err
}
props := make([]Property, 0, len(m))
for _, prop := range m {
props = append(props, prop)
}
_, err = d.Patch([]Proppatch{{Props: props}})
return err
}
// copyFiles copies files and/or directories from src to dst.
//
// See section 9.8.5 for when various HTTP status codes apply.
func copyFiles(ctx context.Context, fs FileSystem, src, dst string, overwrite bool, depth int, recursion int) (status int, err error) {
if recursion == 1000 {
return http.StatusInternalServerError, errRecursionTooDeep
}
recursion++
// TODO: section 9.8.3 says that "Note that an infinite-depth COPY of /A/
// into /A/B/ could lead to infinite recursion if not handled correctly."
srcFile, err := fs.OpenFile(ctx, src, os.O_RDONLY, 0)
if err != nil {
if os.IsNotExist(err) {
return http.StatusNotFound, err
}
return http.StatusInternalServerError, err
}
defer srcFile.Close()
srcStat, err := srcFile.Stat()
if err != nil {
if os.IsNotExist(err) {
return http.StatusNotFound, err
}
return http.StatusInternalServerError, err
}
srcPerm := srcStat.Mode() & os.ModePerm
created := false
if _, err := fs.Stat(ctx, dst); err != nil {
if os.IsNotExist(err) {
created = true
} else {
return http.StatusForbidden, err
}
} else {
if !overwrite {
return http.StatusPreconditionFailed, os.ErrExist
}
if err := fs.RemoveAll(ctx, dst); err != nil && !os.IsNotExist(err) {
return http.StatusForbidden, err
}
}
if srcStat.IsDir() {
if err := fs.Mkdir(ctx, dst, srcPerm); err != nil {
return http.StatusForbidden, err
}
if depth == infiniteDepth {
children, err := srcFile.Readdir(-1)
if err != nil {
return http.StatusForbidden, err
}
for _, c := range children {
name := c.Name()
s := path.Join(src, name)
d := path.Join(dst, name)
cStatus, cErr := copyFiles(ctx, fs, s, d, overwrite, depth, recursion)
if cErr != nil {
// TODO: MultiStatus.
return cStatus, cErr
}
}
}
} else {
dstFile, err := fs.OpenFile(ctx, dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, srcPerm)
if err != nil {
if os.IsNotExist(err) {
return http.StatusConflict, err
}
return http.StatusForbidden, err
}
_, copyErr := io.Copy(dstFile, srcFile)
propsErr := copyProps(dstFile, srcFile)
closeErr := dstFile.Close()
if copyErr != nil {
return http.StatusInternalServerError, copyErr
}
if propsErr != nil {
return http.StatusInternalServerError, propsErr
}
if closeErr != nil {
return http.StatusInternalServerError, closeErr
}
}
if created {
return http.StatusCreated, nil
}
return http.StatusNoContent, nil
}
// walkFS traverses filesystem fs starting at name up to depth levels.
//
// Allowed values for depth are 0, 1 or infiniteDepth. For each visited node,
// walkFS calls walkFn. If a visited file system node is a directory and
// walkFn returns filepath.SkipDir, walkFS will skip traversal of this node.
func walkFS(ctx context.Context, fs FileSystem, depth int, name string, info os.FileInfo, walkFn filepath.WalkFunc) error {
// This implementation is based on Walk's code in the standard path/filepath package.
err := walkFn(name, info, nil)
if err != nil {
if info.IsDir() && err == filepath.SkipDir {
return nil
}
return err
}
if !info.IsDir() || depth == 0 {
return nil
}
if depth == 1 {
depth = 0
}
// Read directory names.
f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return walkFn(name, info, err)
}
fileInfos, err := f.Readdir(0)
f.Close()
if err != nil {
return walkFn(name, info, err)
}
for _, fileInfo := range fileInfos {
filename := path.Join(name, fileInfo.Name())
fileInfo, err := fs.Stat(ctx, filename)
if err != nil {
if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir {
return err
}
} else {
err = walkFS(ctx, fs, depth, filename, fileInfo, walkFn)
if err != nil {
if !fileInfo.IsDir() || err != filepath.SkipDir {
return err
}
}
}
}
return nil
}

17
file_go1.6.go Normal file
View File

@ -0,0 +1,17 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !go1.7
package webdav
import (
"net/http"
"golang.org/x/net/context"
)
func getContext(r *http.Request) context.Context {
return context.Background()
}

16
file_go1.7.go Normal file
View File

@ -0,0 +1,16 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build go1.7
package webdav
import (
"context"
"net/http"
)
func getContext(r *http.Request) context.Context {
return r.Context()
}

1184
file_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,301 +0,0 @@
package webdav
import (
"context"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/emersion/go-webdav/internal"
)
// LocalFileSystem implements FileSystem for a local directory.
type LocalFileSystem string
var _ FileSystem = LocalFileSystem("")
func (fs LocalFileSystem) localPath(name string) (string, error) {
if (filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0) || strings.Contains(name, "\x00") {
return "", internal.HTTPErrorf(http.StatusBadRequest, "webdav: invalid character in path")
}
name = path.Clean(name)
if !path.IsAbs(name) {
return "", internal.HTTPErrorf(http.StatusBadRequest, "webdav: expected absolute path, got %q", name)
}
return filepath.Join(string(fs), filepath.FromSlash(name)), nil
}
func (fs LocalFileSystem) externalPath(name string) (string, error) {
rel, err := filepath.Rel(string(fs), name)
if err != nil {
return "", err
}
return "/" + filepath.ToSlash(rel), nil
}
func (fs LocalFileSystem) Open(ctx context.Context, name string) (io.ReadCloser, error) {
p, err := fs.localPath(name)
if err != nil {
return nil, err
}
return os.Open(p)
}
func fileInfoFromOS(p string, fi os.FileInfo) *FileInfo {
return &FileInfo{
Path: p,
Size: fi.Size(),
ModTime: fi.ModTime(),
IsDir: fi.IsDir(),
// TODO: fallback to http.DetectContentType?
MIMEType: mime.TypeByExtension(path.Ext(p)),
// RFC 2616 section 13.3.3 describes strong ETags. Ideally these would
// be checksums or sequence numbers, however these are expensive to
// compute. The modification time with nanosecond granularity is good
// enough, as it's very unlikely for the same file to be modified twice
// during a single nanosecond.
ETag: fmt.Sprintf("%x%x", fi.ModTime().UnixNano(), fi.Size()),
}
}
func errFromOS(err error) error {
if os.IsNotExist(err) {
return NewHTTPError(http.StatusNotFound, err)
} else if os.IsPermission(err) {
return NewHTTPError(http.StatusForbidden, err)
} else if os.IsTimeout(err) {
return NewHTTPError(http.StatusServiceUnavailable, err)
} else {
return err
}
}
func (fs LocalFileSystem) Stat(ctx context.Context, name string) (*FileInfo, error) {
p, err := fs.localPath(name)
if err != nil {
return nil, err
}
fi, err := os.Stat(p)
if err != nil {
return nil, errFromOS(err)
}
return fileInfoFromOS(name, fi), nil
}
func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) {
path, err := fs.localPath(name)
if err != nil {
return nil, err
}
var l []FileInfo
err = filepath.Walk(path, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
href, err := fs.externalPath(p)
if err != nil {
return err
}
l = append(l, *fileInfoFromOS(href, fi))
if !recursive && fi.IsDir() && path != p {
return filepath.SkipDir
}
return nil
})
return l, errFromOS(err)
}
func (fs LocalFileSystem) Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fi *FileInfo, created bool, err error) {
p, err := fs.localPath(name)
if err != nil {
return nil, false, err
}
fi, _ = fs.Stat(ctx, name)
created = fi == nil
etag := ""
if fi != nil {
etag = fi.ETag
}
if opts.IfMatch.IsSet() {
if ok, err := opts.IfMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if !ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-Match condition failed"))
}
}
if opts.IfNoneMatch.IsSet() {
if ok, err := opts.IfNoneMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-None-Match condition failed"))
}
}
wc, err := os.Create(p)
if err != nil {
return nil, false, errFromOS(err)
}
defer wc.Close()
if _, err := io.Copy(wc, body); err != nil {
os.Remove(p)
return nil, false, err
}
if err := wc.Close(); err != nil {
os.Remove(p)
return nil, false, err
}
fi, err = fs.Stat(ctx, name)
if err != nil {
return nil, false, err
}
return fi, created, err
}
func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error {
p, err := fs.localPath(name)
if err != nil {
return err
}
// WebDAV semantics are that it should return a "404 Not Found" error in
// case the resource doesn't exist. We need to Stat before RemoveAll.
if _, err = os.Stat(p); err != nil {
return errFromOS(err)
}
return errFromOS(os.RemoveAll(p))
}
func (fs LocalFileSystem) Mkdir(ctx context.Context, name string) error {
p, err := fs.localPath(name)
if err != nil {
return err
}
return errFromOS(os.Mkdir(p, 0755))
}
func copyRegularFile(src, dst string, perm os.FileMode) error {
srcFile, err := os.Open(src)
if err != nil {
return errFromOS(err)
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if os.IsNotExist(err) {
return NewHTTPError(http.StatusConflict, err)
} else if err != nil {
return errFromOS(err)
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return dstFile.Close()
}
func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *CopyOptions) (created bool, err error) {
srcPath, err := fs.localPath(src)
if err != nil {
return false, err
}
dstPath, err := fs.localPath(dst)
if err != nil {
return false, err
}
// TODO: "Note that an infinite-depth COPY of /A/ into /A/B/ could lead to
// infinite recursion if not handled correctly"
srcInfo, err := os.Stat(srcPath)
if err != nil {
return false, errFromOS(err)
}
srcPerm := srcInfo.Mode() & os.ModePerm
if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) {
return false, errFromOS(err)
}
created = true
} else {
if options.NoOverwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
}
if err := os.RemoveAll(dstPath); err != nil {
return false, errFromOS(err)
}
}
err = filepath.Walk(srcPath, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
if err := os.Mkdir(dstPath, srcPerm); err != nil {
return errFromOS(err)
}
} else {
if err := copyRegularFile(srcPath, dstPath, srcPerm); err != nil {
return err
}
}
if fi.IsDir() && options.NoRecursive {
return filepath.SkipDir
}
return nil
})
if err != nil {
return false, errFromOS(err)
}
return created, nil
}
func (fs LocalFileSystem) Move(ctx context.Context, src, dst string, options *MoveOptions) (created bool, err error) {
srcPath, err := fs.localPath(src)
if err != nil {
return false, err
}
dstPath, err := fs.localPath(dst)
if err != nil {
return false, err
}
if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) {
return false, errFromOS(err)
}
created = true
} else {
if options.NoOverwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
}
if err := os.RemoveAll(dstPath); err != nil {
return false, errFromOS(err)
}
}
if err := os.Rename(srcPath, dstPath); err != nil {
return false, errFromOS(err)
}
return created, nil
}

4
go.mod
View File

@ -3,6 +3,6 @@ module github.com/emersion/go-webdav
go 1.13
require (
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553
)

13
go.sum
View File

@ -1,6 +1,7 @@
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 h1:n9qx98xiS5V4x2WIpPC2rr9mUM5ri9r/YhCEKbhCHro=
github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5/go.mod h1:WIi9g8OKJQHXtQbx7GExlo6UAFaui9WDMYabJ+Be4WI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

173
if.go Normal file
View File

@ -0,0 +1,173 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
// The If header is covered by Section 10.4.
// http://www.webdav.org/specs/rfc4918.html#HEADER_If
import (
"strings"
)
// ifHeader is a disjunction (OR) of ifLists.
type ifHeader struct {
lists []ifList
}
// ifList is a conjunction (AND) of Conditions, and an optional resource tag.
type ifList struct {
resourceTag string
conditions []Condition
}
// parseIfHeader parses the "If: foo bar" HTTP header. The httpHeader string
// should omit the "If:" prefix and have any "\r\n"s collapsed to a " ", as is
// returned by req.Header.Get("If") for a http.Request req.
func parseIfHeader(httpHeader string) (h ifHeader, ok bool) {
s := strings.TrimSpace(httpHeader)
switch tokenType, _, _ := lex(s); tokenType {
case '(':
return parseNoTagLists(s)
case angleTokenType:
return parseTaggedLists(s)
default:
return ifHeader{}, false
}
}
func parseNoTagLists(s string) (h ifHeader, ok bool) {
for {
l, remaining, ok := parseList(s)
if !ok {
return ifHeader{}, false
}
h.lists = append(h.lists, l)
if remaining == "" {
return h, true
}
s = remaining
}
}
func parseTaggedLists(s string) (h ifHeader, ok bool) {
resourceTag, n := "", 0
for first := true; ; first = false {
tokenType, tokenStr, remaining := lex(s)
switch tokenType {
case angleTokenType:
if !first && n == 0 {
return ifHeader{}, false
}
resourceTag, n = tokenStr, 0
s = remaining
case '(':
n++
l, remaining, ok := parseList(s)
if !ok {
return ifHeader{}, false
}
l.resourceTag = resourceTag
h.lists = append(h.lists, l)
if remaining == "" {
return h, true
}
s = remaining
default:
return ifHeader{}, false
}
}
}
func parseList(s string) (l ifList, remaining string, ok bool) {
tokenType, _, s := lex(s)
if tokenType != '(' {
return ifList{}, "", false
}
for {
tokenType, _, remaining = lex(s)
if tokenType == ')' {
if len(l.conditions) == 0 {
return ifList{}, "", false
}
return l, remaining, true
}
c, remaining, ok := parseCondition(s)
if !ok {
return ifList{}, "", false
}
l.conditions = append(l.conditions, c)
s = remaining
}
}
func parseCondition(s string) (c Condition, remaining string, ok bool) {
tokenType, tokenStr, s := lex(s)
if tokenType == notTokenType {
c.Not = true
tokenType, tokenStr, s = lex(s)
}
switch tokenType {
case strTokenType, angleTokenType:
c.Token = tokenStr
case squareTokenType:
c.ETag = tokenStr
default:
return Condition{}, "", false
}
return c, s, true
}
// Single-rune tokens like '(' or ')' have a token type equal to their rune.
// All other tokens have a negative token type.
const (
errTokenType = rune(-1)
eofTokenType = rune(-2)
strTokenType = rune(-3)
notTokenType = rune(-4)
angleTokenType = rune(-5)
squareTokenType = rune(-6)
)
func lex(s string) (tokenType rune, tokenStr string, remaining string) {
// The net/textproto Reader that parses the HTTP header will collapse
// Linear White Space that spans multiple "\r\n" lines to a single " ",
// so we don't need to look for '\r' or '\n'.
for len(s) > 0 && (s[0] == '\t' || s[0] == ' ') {
s = s[1:]
}
if len(s) == 0 {
return eofTokenType, "", ""
}
i := 0
loop:
for ; i < len(s); i++ {
switch s[i] {
case '\t', ' ', '(', ')', '<', '>', '[', ']':
break loop
}
}
if i != 0 {
tokenStr, remaining = s[:i], s[i:]
if tokenStr == "Not" {
return notTokenType, "", remaining
}
return strTokenType, tokenStr, remaining
}
j := 0
switch s[0] {
case '<':
j, tokenType = strings.IndexByte(s, '>'), angleTokenType
case '[':
j, tokenType = strings.IndexByte(s, ']'), squareTokenType
default:
return rune(s[0]), "", s[1:]
}
if j < 0 {
return errTokenType, "", ""
}
return tokenType, s[1:j], s[j+1:]
}

322
if_test.go Normal file
View File

@ -0,0 +1,322 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"reflect"
"strings"
"testing"
)
func TestParseIfHeader(t *testing.T) {
// The "section x.y.z" test cases come from section x.y.z of the spec at
// http://www.webdav.org/specs/rfc4918.html
testCases := []struct {
desc string
input string
want ifHeader
}{{
"bad: empty",
``,
ifHeader{},
}, {
"bad: no parens",
`foobar`,
ifHeader{},
}, {
"bad: empty list #1",
`()`,
ifHeader{},
}, {
"bad: empty list #2",
`(a) (b c) () (d)`,
ifHeader{},
}, {
"bad: no list after resource #1",
`<foo>`,
ifHeader{},
}, {
"bad: no list after resource #2",
`<foo> <bar> (a)`,
ifHeader{},
}, {
"bad: no list after resource #3",
`<foo> (a) (b) <bar>`,
ifHeader{},
}, {
"bad: no-tag-list followed by tagged-list",
`(a) (b) <foo> (c)`,
ifHeader{},
}, {
"bad: unfinished list",
`(a`,
ifHeader{},
}, {
"bad: unfinished ETag",
`([b`,
ifHeader{},
}, {
"bad: unfinished Notted list",
`(Not a`,
ifHeader{},
}, {
"bad: double Not",
`(Not Not a)`,
ifHeader{},
}, {
"good: one list with a Token",
`(a)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `a`,
}},
}},
},
}, {
"good: one list with an ETag",
`([a])`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
ETag: `a`,
}},
}},
},
}, {
"good: one list with three Nots",
`(Not a Not b Not [d])`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Not: true,
Token: `a`,
}, {
Not: true,
Token: `b`,
}, {
Not: true,
ETag: `d`,
}},
}},
},
}, {
"good: two lists",
`(a) (b)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `a`,
}},
}, {
conditions: []Condition{{
Token: `b`,
}},
}},
},
}, {
"good: two Notted lists",
`(Not a) (Not b)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Not: true,
Token: `a`,
}},
}, {
conditions: []Condition{{
Not: true,
Token: `b`,
}},
}},
},
}, {
"section 7.5.1",
`<http://www.example.com/users/f/fielding/index.html>
(<urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)`,
ifHeader{
lists: []ifList{{
resourceTag: `http://www.example.com/users/f/fielding/index.html`,
conditions: []Condition{{
Token: `urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6`,
}},
}},
},
}, {
"section 7.5.2 #1",
`(<urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf>)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
}},
}},
},
}, {
"section 7.5.2 #2",
`<http://example.com/locked/>
(<urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf>)`,
ifHeader{
lists: []ifList{{
resourceTag: `http://example.com/locked/`,
conditions: []Condition{{
Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
}},
}},
},
}, {
"section 7.5.2 #3",
`<http://example.com/locked/member>
(<urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf>)`,
ifHeader{
lists: []ifList{{
resourceTag: `http://example.com/locked/member`,
conditions: []Condition{{
Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
}},
}},
},
}, {
"section 9.9.6",
`(<urn:uuid:fe184f2e-6eec-41d0-c765-01adc56e6bb4>)
(<urn:uuid:e454f3f3-acdc-452a-56c7-00a5c91e4b77>)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `urn:uuid:fe184f2e-6eec-41d0-c765-01adc56e6bb4`,
}},
}, {
conditions: []Condition{{
Token: `urn:uuid:e454f3f3-acdc-452a-56c7-00a5c91e4b77`,
}},
}},
},
}, {
"section 9.10.8",
`(<urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4>)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4`,
}},
}},
},
}, {
"section 10.4.6",
`(<urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>
["I am an ETag"])
(["I am another ETag"])`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
}, {
ETag: `"I am an ETag"`,
}},
}, {
conditions: []Condition{{
ETag: `"I am another ETag"`,
}},
}},
},
}, {
"section 10.4.7",
`(Not <urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>
<urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092>)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Not: true,
Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
}, {
Token: `urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092`,
}},
}},
},
}, {
"section 10.4.8",
`(<urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>)
(Not <DAV:no-lock>)`,
ifHeader{
lists: []ifList{{
conditions: []Condition{{
Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
}},
}, {
conditions: []Condition{{
Not: true,
Token: `DAV:no-lock`,
}},
}},
},
}, {
"section 10.4.9",
`</resource1>
(<urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>
[W/"A weak ETag"]) (["strong ETag"])`,
ifHeader{
lists: []ifList{{
resourceTag: `/resource1`,
conditions: []Condition{{
Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
}, {
ETag: `W/"A weak ETag"`,
}},
}, {
resourceTag: `/resource1`,
conditions: []Condition{{
ETag: `"strong ETag"`,
}},
}},
},
}, {
"section 10.4.10",
`<http://www.example.com/specs/>
(<urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>)`,
ifHeader{
lists: []ifList{{
resourceTag: `http://www.example.com/specs/`,
conditions: []Condition{{
Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
}},
}},
},
}, {
"section 10.4.11 #1",
`</specs/rfc2518.doc> (["4217"])`,
ifHeader{
lists: []ifList{{
resourceTag: `/specs/rfc2518.doc`,
conditions: []Condition{{
ETag: `"4217"`,
}},
}},
},
}, {
"section 10.4.11 #2",
`</specs/rfc2518.doc> (Not ["4217"])`,
ifHeader{
lists: []ifList{{
resourceTag: `/specs/rfc2518.doc`,
conditions: []Condition{{
Not: true,
ETag: `"4217"`,
}},
}},
},
}}
for _, tc := range testCases {
got, ok := parseIfHeader(strings.Replace(tc.input, "\n", "", -1))
if gotEmpty := reflect.DeepEqual(got, ifHeader{}); gotEmpty == ok {
t.Errorf("%s: should be different: empty header == %t, ok == %t", tc.desc, gotEmpty, ok)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s:\ngot %v\nwant %v", tc.desc, got, tc.want)
continue
}
}
}

View File

@ -1,256 +0,0 @@
package internal
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"path"
"strings"
"unicode"
)
// DiscoverContextURL performs a DNS-based CardDAV/CalDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, service, domain string) (string, error) {
var resolver net.Resolver
// Only lookup TLS records, plaintext connections are insecure
_, addrs, err := resolver.LookupSRV(ctx, service+"s", "tcp", domain)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsTemporary {
return "", err
}
} else if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("webdav: domain doesn't have an SRV record")
}
addr := addrs[0]
target := strings.TrimSuffix(addr.Target, ".")
if target == "" {
return "", fmt.Errorf("webdav: empty target in SRV record")
}
// TODO: perform a TXT lookup, check for a "path" key in the response
u := url.URL{Scheme: "https"}
if addr.Port == 443 {
u.Host = target
} else {
u.Host = fmt.Sprintf("%v:%v", target, addr.Port)
}
u.Path = "/.well-known/" + service
return u.String(), nil
}
// HTTPClient performs HTTP requests. It's implemented by *http.Client.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type Client struct {
http HTTPClient
endpoint *url.URL
}
func NewClient(c HTTPClient, endpoint string) (*Client, error) {
if c == nil {
c = http.DefaultClient
}
u, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
if u.Path == "" {
// This is important to avoid issues with path.Join
u.Path = "/"
}
return &Client{http: c, endpoint: u}, nil
}
func (c *Client) ResolveHref(p string) *url.URL {
if !strings.HasPrefix(p, "/") {
p = path.Join(c.endpoint.Path, p)
}
return &url.URL{
Scheme: c.endpoint.Scheme,
User: c.endpoint.User,
Host: c.endpoint.Host,
Path: p,
}
}
func (c *Client) NewRequest(method string, path string, body io.Reader) (*http.Request, error) {
return http.NewRequest(method, c.ResolveHref(path).String(), body)
}
func (c *Client) NewXMLRequest(method string, path string, v interface{}) (*http.Request, error) {
var buf bytes.Buffer
buf.WriteString(xml.Header)
if err := xml.NewEncoder(&buf).Encode(v); err != nil {
return nil, err
}
req, err := c.NewRequest(method, path, &buf)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "text/xml; charset=\"utf-8\"")
return req, nil
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
}
var wrappedErr error
t, _, _ := mime.ParseMediaType(contentType)
if t == "application/xml" || t == "text/xml" {
var davErr Error
if err := xml.NewDecoder(resp.Body).Decode(&davErr); err != nil {
wrappedErr = err
} else {
wrappedErr = &davErr
}
} else if strings.HasPrefix(t, "text/") {
lr := io.LimitedReader{R: resp.Body, N: 1024}
var buf bytes.Buffer
io.Copy(&buf, &lr)
resp.Body.Close()
if s := strings.TrimSpace(buf.String()); s != "" {
if lr.N == 0 {
s += " […]"
}
wrappedErr = fmt.Errorf("%v", s)
}
}
return nil, &HTTPError{Code: resp.StatusCode, Err: wrappedErr}
}
return resp, nil
}
func (c *Client) DoMultiStatus(req *http.Request) (*MultiStatus, error) {
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("HTTP multi-status request failed: %v", resp.Status)
}
// TODO: the response can be quite large, support streaming Response elements
var ms MultiStatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, err
}
return &ms, nil
}
func (c *Client) PropFind(ctx context.Context, path string, depth Depth, propfind *PropFind) (*MultiStatus, error) {
req, err := c.NewXMLRequest("PROPFIND", path, propfind)
if err != nil {
return nil, err
}
req.Header.Add("Depth", depth.String())
return c.DoMultiStatus(req.WithContext(ctx))
}
// PropfindFlat performs a PROPFIND request with a zero depth.
func (c *Client) PropFindFlat(ctx context.Context, path string, propfind *PropFind) (*Response, error) {
ms, err := c.PropFind(ctx, path, DepthZero, propfind)
if err != nil {
return nil, err
}
// If the client followed a redirect, the Href might be different from the request path
if len(ms.Responses) != 1 {
return nil, fmt.Errorf("PROPFIND with Depth: 0 returned %d responses", len(ms.Responses))
}
return &ms.Responses[0], nil
}
func parseCommaSeparatedSet(values []string, upper bool) map[string]bool {
m := make(map[string]bool)
for _, v := range values {
fields := strings.FieldsFunc(v, func(r rune) bool {
return unicode.IsSpace(r) || r == ','
})
for _, f := range fields {
if upper {
f = strings.ToUpper(f)
} else {
f = strings.ToLower(f)
}
m[f] = true
}
}
return m
}
func (c *Client) Options(ctx context.Context, path string) (classes map[string]bool, methods map[string]bool, err error) {
req, err := c.NewRequest(http.MethodOptions, path, nil)
if err != nil {
return nil, nil, err
}
resp, err := c.Do(req.WithContext(ctx))
if err != nil {
return nil, nil, err
}
resp.Body.Close()
classes = parseCommaSeparatedSet(resp.Header["Dav"], false)
if !classes["1"] {
return nil, nil, fmt.Errorf("webdav: server doesn't support DAV class 1")
}
methods = parseCommaSeparatedSet(resp.Header["Allow"], true)
return classes, methods, nil
}
// SyncCollection perform a `sync-collection` REPORT operation on a resource
func (c *Client) SyncCollection(ctx context.Context, path, syncToken string, level Depth, limit *Limit, prop *Prop) (*MultiStatus, error) {
q := SyncCollectionQuery{
SyncToken: syncToken,
SyncLevel: level.String(),
Limit: limit,
Prop: prop,
}
req, err := c.NewXMLRequest("REPORT", path, &q)
if err != nil {
return nil, err
}
ms, err := c.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
return ms, nil
}

View File

@ -1,479 +0,0 @@
package internal
import (
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const Namespace = "DAV:"
var (
ResourceTypeName = xml.Name{Namespace, "resourcetype"}
DisplayNameName = xml.Name{Namespace, "displayname"}
GetContentLengthName = xml.Name{Namespace, "getcontentlength"}
GetContentTypeName = xml.Name{Namespace, "getcontenttype"}
GetLastModifiedName = xml.Name{Namespace, "getlastmodified"}
GetETagName = xml.Name{Namespace, "getetag"}
CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"}
CurrentUserPrivilegeSetName = xml.Name{Namespace, "current-user-privilege-set"}
)
type Status struct {
Code int
Text string
}
func (s *Status) MarshalText() ([]byte, error) {
text := s.Text
if text == "" {
text = http.StatusText(s.Code)
}
return []byte(fmt.Sprintf("HTTP/1.1 %v %v", s.Code, text)), nil
}
func (s *Status) UnmarshalText(b []byte) error {
if len(b) == 0 {
return nil
}
parts := strings.SplitN(string(b), " ", 3)
if len(parts) != 3 {
return fmt.Errorf("webdav: invalid HTTP status %q: expected 3 fields", s)
}
code, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("webdav: invalid HTTP status %q: failed to parse code: %v", s, err)
}
s.Code = code
s.Text = parts[2]
return nil
}
func (s *Status) Err() error {
if s == nil {
return nil
}
// TODO: handle 2xx, 3xx
if s.Code != http.StatusOK {
return &HTTPError{Code: s.Code}
}
return nil
}
type Href url.URL
func (h *Href) String() string {
u := (*url.URL)(h)
return u.String()
}
func (h *Href) MarshalText() ([]byte, error) {
return []byte(h.String()), nil
}
func (h *Href) UnmarshalText(b []byte) error {
u, err := url.Parse(string(b))
if err != nil {
return err
}
*h = Href(*u)
return nil
}
// https://tools.ietf.org/html/rfc4918#section-14.16
type MultiStatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []Response `xml:"response"`
ResponseDescription string `xml:"responsedescription,omitempty"`
SyncToken string `xml:"sync-token,omitempty"`
}
func NewMultiStatus(resps ...Response) *MultiStatus {
return &MultiStatus{Responses: resps}
}
// https://tools.ietf.org/html/rfc4918#section-14.24
type Response struct {
XMLName xml.Name `xml:"DAV: response"`
Hrefs []Href `xml:"href"`
PropStats []PropStat `xml:"propstat,omitempty"`
ResponseDescription string `xml:"responsedescription,omitempty"`
Status *Status `xml:"status,omitempty"`
Error *Error `xml:"error,omitempty"`
Location *Location `xml:"location,omitempty"`
}
func NewOKResponse(path string) *Response {
href := Href{Path: path}
return &Response{
Hrefs: []Href{href},
Status: &Status{Code: http.StatusOK},
}
}
func NewErrorResponse(path string, err error) *Response {
code := http.StatusInternalServerError
var httpErr *HTTPError
if errors.As(err, &httpErr) {
code = httpErr.Code
}
var errElt *Error
errors.As(err, &errElt)
href := Href{Path: path}
return &Response{
Hrefs: []Href{href},
Status: &Status{Code: code},
ResponseDescription: err.Error(),
Error: errElt,
}
}
func (resp *Response) Err() error {
if resp.Status == nil || resp.Status.Code/100 == 2 {
return nil
}
var err error
if resp.Error != nil {
err = resp.Error
}
if resp.ResponseDescription != "" {
if err != nil {
err = fmt.Errorf("%v (%w)", resp.ResponseDescription, err)
} else {
err = fmt.Errorf("%v", resp.ResponseDescription)
}
}
return &HTTPError{
Code: resp.Status.Code,
Err: err,
}
}
func (resp *Response) Path() (string, error) {
err := resp.Err()
var path string
if len(resp.Hrefs) == 1 {
path = resp.Hrefs[0].Path
} else if err == nil {
err = fmt.Errorf("webdav: malformed response: expected exactly one href element, got %v", len(resp.Hrefs))
}
return path, err
}
func (resp *Response) DecodeProp(values ...interface{}) error {
for _, v := range values {
// TODO wrap errors with more context (XML name)
name, err := valueXMLName(v)
if err != nil {
return err
}
if err := resp.Err(); err != nil {
return newPropError(name, err)
}
for _, propstat := range resp.PropStats {
raw := propstat.Prop.Get(name)
if raw == nil {
continue
}
if err := propstat.Status.Err(); err != nil {
return newPropError(name, err)
}
if err := raw.Decode(v); err != nil {
return newPropError(name, err)
}
return nil
}
return newPropError(name, &HTTPError{
Code: http.StatusNotFound,
Err: fmt.Errorf("missing property"),
})
}
return nil
}
func newPropError(name xml.Name, err error) error {
return fmt.Errorf("property <%v %v>: %w", name.Space, name.Local, err)
}
func (resp *Response) EncodeProp(code int, v interface{}) error {
raw, err := EncodeRawXMLElement(v)
if err != nil {
return err
}
for i := range resp.PropStats {
propstat := &resp.PropStats[i]
if propstat.Status.Code == code {
propstat.Prop.Raw = append(propstat.Prop.Raw, *raw)
return nil
}
}
resp.PropStats = append(resp.PropStats, PropStat{
Status: Status{Code: code},
Prop: Prop{Raw: []RawXMLValue{*raw}},
})
return nil
}
// https://tools.ietf.org/html/rfc4918#section-14.9
type Location struct {
XMLName xml.Name `xml:"DAV: location"`
Href Href `xml:"href"`
}
// https://tools.ietf.org/html/rfc4918#section-14.22
type PropStat struct {
XMLName xml.Name `xml:"DAV: propstat"`
Prop Prop `xml:"prop"`
Status Status `xml:"status"`
ResponseDescription string `xml:"responsedescription,omitempty"`
Error *Error `xml:"error,omitempty"`
}
// https://tools.ietf.org/html/rfc4918#section-14.18
type Prop struct {
XMLName xml.Name `xml:"DAV: prop"`
Raw []RawXMLValue `xml:",any"`
}
func EncodeProp(values ...interface{}) (*Prop, error) {
l := make([]RawXMLValue, len(values))
for i, v := range values {
raw, err := EncodeRawXMLElement(v)
if err != nil {
return nil, err
}
l[i] = *raw
}
return &Prop{Raw: l}, nil
}
func (p *Prop) Get(name xml.Name) *RawXMLValue {
for i := range p.Raw {
raw := &p.Raw[i]
if n, ok := raw.XMLName(); ok && name == n {
return raw
}
}
return nil
}
func (p *Prop) Decode(v interface{}) error {
name, err := valueXMLName(v)
if err != nil {
return err
}
raw := p.Get(name)
if raw == nil {
return HTTPErrorf(http.StatusNotFound, "missing property %s", name)
}
return raw.Decode(v)
}
// https://tools.ietf.org/html/rfc4918#section-14.20
type PropFind struct {
XMLName xml.Name `xml:"DAV: propfind"`
Prop *Prop `xml:"prop,omitempty"`
AllProp *struct{} `xml:"allprop,omitempty"`
Include *Include `xml:"include,omitempty"`
PropName *struct{} `xml:"propname,omitempty"`
}
func xmlNamesToRaw(names []xml.Name) []RawXMLValue {
l := make([]RawXMLValue, len(names))
for i, name := range names {
l[i] = *NewRawXMLElement(name, nil, nil)
}
return l
}
func NewPropNamePropFind(names ...xml.Name) *PropFind {
return &PropFind{Prop: &Prop{Raw: xmlNamesToRaw(names)}}
}
// https://tools.ietf.org/html/rfc4918#section-14.8
type Include struct {
XMLName xml.Name `xml:"DAV: include"`
Raw []RawXMLValue `xml:",any"`
}
// https://tools.ietf.org/html/rfc4918#section-15.9
type ResourceType struct {
XMLName xml.Name `xml:"DAV: resourcetype"`
Raw []RawXMLValue `xml:",any"`
}
func NewResourceType(names ...xml.Name) *ResourceType {
return &ResourceType{Raw: xmlNamesToRaw(names)}
}
func (t *ResourceType) Is(name xml.Name) bool {
for _, raw := range t.Raw {
if n, ok := raw.XMLName(); ok && name == n {
return true
}
}
return false
}
var CollectionName = xml.Name{Namespace, "collection"}
// https://tools.ietf.org/html/rfc4918#section-15.4
type GetContentLength struct {
XMLName xml.Name `xml:"DAV: getcontentlength"`
Length int64 `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc4918#section-15.5
type GetContentType struct {
XMLName xml.Name `xml:"DAV: getcontenttype"`
Type string `xml:",chardata"`
}
type Time time.Time
func (t *Time) UnmarshalText(b []byte) error {
tt, err := http.ParseTime(string(b))
if err != nil {
return errors.New(err.Error() + " : time_data : " + base64.StdEncoding.EncodeToString(b))
}
*t = Time(tt)
return nil
}
func (t *Time) MarshalText() ([]byte, error) {
s := time.Time(*t).UTC().Format(http.TimeFormat)
return []byte(s), nil
}
// https://tools.ietf.org/html/rfc4918#section-15.7
type GetLastModified struct {
XMLName xml.Name `xml:"DAV: getlastmodified"`
LastModified Time `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc4918#section-15.6
type GetETag struct {
XMLName xml.Name `xml:"DAV: getetag"`
ETag ETag `xml:",chardata"`
}
type ETag string
func (etag *ETag) UnmarshalText(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return fmt.Errorf("webdav: failed to unquote ETag: %v", err)
}
*etag = ETag(s)
return nil
}
func (etag ETag) MarshalText() ([]byte, error) {
return []byte(etag.String()), nil
}
func (etag ETag) String() string {
return fmt.Sprintf("%q", string(etag))
}
// https://tools.ietf.org/html/rfc4918#section-14.5
type Error struct {
XMLName xml.Name `xml:"DAV: error"`
Raw []RawXMLValue `xml:",any"`
}
func (err *Error) Error() string {
b, _ := xml.Marshal(err)
return string(b)
}
// https://tools.ietf.org/html/rfc4918#section-15.2
type DisplayName struct {
XMLName xml.Name `xml:"DAV: displayname"`
Name string `xml:",chardata"`
}
// https://tools.ietf.org/html/rfc5397#section-3
type CurrentUserPrincipal struct {
XMLName xml.Name `xml:"DAV: current-user-principal"`
Href Href `xml:"href,omitempty"`
Unauthenticated *struct{} `xml:"unauthenticated,omitempty"`
}
type CurrentUserPrivilegeSet struct {
XMLName xml.Name `xml:"DAV: current-user-privilege-set"`
Privilege []Privilege `xml:"privilege"`
}
type Privilege struct {
XMLName xml.Name `xml:"DAV: privilege"`
Read *struct{} `xml:"DAV: read,omitempty"`
All *struct{} `xml:"DAV: all,omitempty"`
Write *struct{} `xml:"DAV: write,omitempty"`
WriteProperties *struct{} `xml:"DAV: write-properties,omitempty"`
WriteContent *struct{} `xml:"DAV: write-content,omitempty"`
}
func NewAllPrivileges() []Privilege {
return []Privilege{
{Read: &struct{}{}},
{All: &struct{}{}},
{Write: &struct{}{}},
{WriteProperties: &struct{}{}},
{WriteContent: &struct{}{}},
}
}
// https://tools.ietf.org/html/rfc4918#section-14.19
type PropertyUpdate struct {
XMLName xml.Name `xml:"DAV: propertyupdate"`
Remove []Remove `xml:"remove"`
Set []Set `xml:"set"`
}
// https://tools.ietf.org/html/rfc4918#section-14.23
type Remove struct {
XMLName xml.Name `xml:"DAV: remove"`
Prop Prop `xml:"prop"`
}
// https://tools.ietf.org/html/rfc4918#section-14.26
type Set struct {
XMLName xml.Name `xml:"DAV: set"`
Prop Prop `xml:"prop"`
}
// https://tools.ietf.org/html/rfc6578#section-6.1
type SyncCollectionQuery struct {
XMLName xml.Name `xml:"DAV: sync-collection"`
SyncToken string `xml:"sync-token"`
Limit *Limit `xml:"limit,omitempty"`
SyncLevel string `xml:"sync-level"`
Prop *Prop `xml:"prop"`
}
// https://tools.ietf.org/html/rfc5323#section-5.17
type Limit struct {
XMLName xml.Name `xml:"DAV: limit"`
NResults uint `xml:"nresults"`
}

View File

@ -1,65 +0,0 @@
package internal
import (
"bytes"
"encoding/xml"
"strings"
"testing"
"time"
)
// https://tools.ietf.org/html/rfc4918#section-9.6.2
const exampleDeleteMultistatusStr = `<?xml version="1.0" encoding="utf-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>http://www.example.com/container/resource3</d:href>
<d:status>HTTP/1.1 423 Locked</d:status>
<d:error><d:lock-token-submitted/></d:error>
</d:response>
</d:multistatus>`
func TestResponse_Err_error(t *testing.T) {
r := strings.NewReader(exampleDeleteMultistatusStr)
var ms MultiStatus
if err := xml.NewDecoder(r).Decode(&ms); err != nil {
t.Fatalf("Decode() = %v", err)
}
if len(ms.Responses) != 1 {
t.Fatalf("expected 1 <response>, got %v", len(ms.Responses))
}
resp := ms.Responses[0]
err := resp.Err()
if err == nil {
t.Errorf("Multistatus.Get() returned a nil error, expected non-nil")
} else if httpErr, ok := err.(*HTTPError); !ok {
t.Errorf("Multistatus.Get() = %T, expected an *HTTPError", err)
} else if httpErr.Code != 423 {
t.Errorf("HTTPError.Code = %v, expected 423", httpErr.Code)
}
}
func TestTimeRoundTrip(t *testing.T) {
now := Time(time.Now().UTC())
want, err := now.MarshalText()
if err != nil {
t.Fatalf("could not marshal time: %+v", err)
}
var got Time
err = got.UnmarshalText(want)
if err != nil {
t.Fatalf("could not unmarshal time: %+v", err)
}
raw, err := got.MarshalText()
if err != nil {
t.Fatalf("could not marshal back: %+v", err)
}
if got, want := raw, want; !bytes.Equal(got, want) {
t.Fatalf("invalid round-trip:\ngot= %s\nwant=%s", got, want)
}
}

View File

@ -1,110 +0,0 @@
// Package internal provides low-level helpers for WebDAV clients and servers.
package internal
import (
"errors"
"fmt"
"net/http"
)
// Depth indicates whether a request applies to the resource's members. It's
// defined in RFC 4918 section 10.2.
type Depth int
const (
// DepthZero indicates that the request applies only to the resource.
DepthZero Depth = 0
// DepthOne indicates that the request applies to the resource and its
// internal members only.
DepthOne Depth = 1
// DepthInfinity indicates that the request applies to the resource and all
// of its members.
DepthInfinity Depth = -1
)
// ParseDepth parses a Depth header.
func ParseDepth(s string) (Depth, error) {
switch s {
case "0":
return DepthZero, nil
case "1":
return DepthOne, nil
case "infinity":
return DepthInfinity, nil
}
return 0, fmt.Errorf("webdav: invalid Depth value")
}
// String formats the depth.
func (d Depth) String() string {
switch d {
case DepthZero:
return "0"
case DepthOne:
return "1"
case DepthInfinity:
return "infinity"
}
panic("webdav: invalid Depth value")
}
// ParseOverwrite parses an Overwrite header.
func ParseOverwrite(s string) (bool, error) {
switch s {
case "T":
return true, nil
case "F":
return false, nil
}
return false, fmt.Errorf("webdav: invalid Overwrite value")
}
// FormatOverwrite formats an Overwrite header.
func FormatOverwrite(overwrite bool) string {
if overwrite {
return "T"
} else {
return "F"
}
}
type HTTPError struct {
Code int
Err error
}
func HTTPErrorFromError(err error) *HTTPError {
if err == nil {
return nil
}
if httpErr, ok := err.(*HTTPError); ok {
return httpErr
} else {
return &HTTPError{http.StatusInternalServerError, err}
}
}
func IsNotFound(err error) bool {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr.Code == http.StatusNotFound
}
return false
}
func HTTPErrorf(code int, format string, a ...interface{}) *HTTPError {
return &HTTPError{code, fmt.Errorf(format, a...)}
}
func (err *HTTPError) Error() string {
s := fmt.Sprintf("%v %v", err.Code, http.StatusText(err.Code))
if err.Err != nil {
return fmt.Sprintf("%v: %v", s, err.Err)
} else {
return s
}
}
func (err *HTTPError) Unwrap() error {
return err.Err
}

View File

@ -1,315 +0,0 @@
package internal
import (
"encoding/xml"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strings"
)
func ServeError(w http.ResponseWriter, err error) {
code := http.StatusInternalServerError
var httpErr *HTTPError
if errors.As(err, &httpErr) {
code = httpErr.Code
}
var errElt *Error
if errors.As(err, &errElt) {
w.WriteHeader(code)
ServeXML(w).Encode(errElt)
return
}
http.Error(w, err.Error(), code)
}
func isContentXML(h http.Header) bool {
t, _, _ := mime.ParseMediaType(h.Get("Content-Type"))
return t == "application/xml" || t == "text/xml"
}
func DecodeXMLRequest(r *http.Request, v interface{}) error {
if !isContentXML(r.Header) {
return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request")
}
if err := xml.NewDecoder(r.Body).Decode(v); err != nil {
return &HTTPError{http.StatusBadRequest, err}
}
return nil
}
func IsRequestBodyEmpty(r *http.Request) bool {
_, err := r.Body.Read(nil)
return err == io.EOF
}
func ServeXML(w http.ResponseWriter) *xml.Encoder {
w.Header().Add("Content-Type", "application/xml; charset=\"utf-8\"")
w.Write([]byte(xml.Header))
return xml.NewEncoder(w)
}
func ServeMultiStatus(w http.ResponseWriter, ms *MultiStatus) error {
// TODO: streaming
w.WriteHeader(http.StatusMultiStatus)
return ServeXML(w).Encode(ms)
}
type Backend interface {
Options(r *http.Request) (caps []string, allow []string, err error)
HeadGet(w http.ResponseWriter, r *http.Request) error
PropFind(r *http.Request, pf *PropFind, depth Depth) (*MultiStatus, error)
PropPatch(r *http.Request, pu *PropertyUpdate) (*Response, error)
Put(w http.ResponseWriter, r *http.Request) error
Delete(r *http.Request) error
Mkcol(r *http.Request) error
Copy(r *http.Request, dest *Href, recursive, overwrite bool) (created bool, err error)
Move(r *http.Request, dest *Href, overwrite bool) (created bool, err error)
}
type Handler struct {
Backend Backend
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var err error
if h.Backend == nil {
err = fmt.Errorf("webdav: no backend available")
} else {
switch r.Method {
case http.MethodOptions:
err = h.handleOptions(w, r)
case http.MethodGet, http.MethodHead:
err = h.Backend.HeadGet(w, r)
case http.MethodPut:
err = h.Backend.Put(w, r)
case http.MethodDelete:
// TODO: send a multistatus in case of partial failure
err = h.Backend.Delete(r)
if err == nil {
w.WriteHeader(http.StatusNoContent)
}
case "PROPFIND":
err = h.handlePropfind(w, r)
case "PROPPATCH":
err = h.handleProppatch(w, r)
case "MKCOL":
err = h.Backend.Mkcol(r)
if err == nil {
w.WriteHeader(http.StatusCreated)
}
case "COPY", "MOVE":
err = h.handleCopyMove(w, r)
default:
err = HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method")
}
}
if err != nil {
ServeError(w, err)
}
}
func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) error {
caps, allow, err := h.Backend.Options(r)
if err != nil {
return err
}
caps = append([]string{"1", "3"}, caps...)
w.Header().Add("DAV", strings.Join(caps, ", "))
w.Header().Add("Allow", strings.Join(allow, ", "))
w.WriteHeader(http.StatusOK)
return nil
}
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
var propfind PropFind
if isContentXML(r.Header) {
if err := DecodeXMLRequest(r, &propfind); err != nil {
return err
}
} else {
var b [1]byte
if _, err := r.Body.Read(b[:]); err != io.EOF {
return HTTPErrorf(http.StatusBadRequest, "webdav: unsupported request body")
}
propfind.AllProp = &struct{}{}
}
depth := DepthInfinity
if s := r.Header.Get("Depth"); s != "" {
var err error
depth, err = ParseDepth(s)
if err != nil {
return &HTTPError{http.StatusBadRequest, err}
}
}
ms, err := h.Backend.PropFind(r, &propfind, depth)
if err != nil {
return err
}
return ServeMultiStatus(w, ms)
}
type PropFindFunc func(raw *RawXMLValue) (interface{}, error)
func PropFindValue(value interface{}) PropFindFunc {
return func(raw *RawXMLValue) (interface{}, error) {
return value, nil
}
}
func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]PropFindFunc) (*Response, error) {
resp := &Response{Hrefs: []Href{Href{Path: path}}}
if _, ok := props[ResourceTypeName]; !ok {
props[ResourceTypeName] = PropFindValue(NewResourceType())
}
if propfind.PropName != nil {
for xmlName, _ := range props {
emptyVal := NewRawXMLElement(xmlName, nil, nil)
if err := resp.EncodeProp(http.StatusOK, emptyVal); err != nil {
return nil, err
}
}
} else if propfind.AllProp != nil {
// TODO: add support for propfind.Include
for xmlName, f := range props {
emptyVal := NewRawXMLElement(xmlName, nil, nil)
val, err := f(emptyVal)
code := http.StatusOK
if err != nil {
code = HTTPErrorFromError(err).Code
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
}
if err := resp.EncodeProp(code, val); err != nil {
return nil, err
}
}
} else if prop := propfind.Prop; prop != nil {
for _, raw := range prop.Raw {
xmlName, ok := raw.XMLName()
if !ok {
continue
}
emptyVal := NewRawXMLElement(xmlName, nil, nil)
var code int
var val interface{} = emptyVal
f, ok := props[xmlName]
if ok {
if v, err := f(&raw); err != nil {
code = HTTPErrorFromError(err).Code
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
} else {
code = http.StatusOK
val = v
}
} else {
code = http.StatusNotFound
}
if err := resp.EncodeProp(code, val); err != nil {
return nil, err
}
}
} else {
return nil, HTTPErrorf(http.StatusBadRequest, "webdav: request missing propname, allprop or prop element")
}
return resp, nil
}
func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) error {
var update PropertyUpdate
if err := DecodeXMLRequest(r, &update); err != nil {
return err
}
resp, err := h.Backend.PropPatch(r, &update)
if err != nil {
return err
}
ms := NewMultiStatus(*resp)
return ServeMultiStatus(w, ms)
}
func parseDestination(h http.Header) (*Href, error) {
destHref := h.Get("Destination")
if destHref == "" {
return nil, HTTPErrorf(http.StatusBadRequest, "webdav: missing Destination header in MOVE request")
}
dest, err := url.Parse(destHref)
if err != nil {
return nil, HTTPErrorf(http.StatusBadRequest, "webdav: marlformed Destination header in MOVE request: %v", err)
}
return (*Href)(dest), nil
}
func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) error {
dest, err := parseDestination(r.Header)
if err != nil {
return err
}
overwrite := true
if s := r.Header.Get("Overwrite"); s != "" {
overwrite, err = ParseOverwrite(s)
if err != nil {
return err
}
}
depth := DepthInfinity
if s := r.Header.Get("Depth"); s != "" {
depth, err = ParseDepth(s)
if err != nil {
return err
}
}
var created bool
if r.Method == "COPY" {
var recursive bool
switch depth {
case DepthZero:
recursive = false
case DepthOne:
return HTTPErrorf(http.StatusBadRequest, `webdav: "Depth: 1" is not supported in COPY request`)
case DepthInfinity:
recursive = true
}
created, err = h.Backend.Copy(r, dest, recursive, overwrite)
} else {
if depth != DepthInfinity {
return HTTPErrorf(http.StatusBadRequest, `webdav: only "Depth: infinity" is accepted in MOVE request`)
}
created, err = h.Backend.Move(r, dest, overwrite)
}
if err != nil {
return err
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
return nil
}

View File

@ -1,175 +0,0 @@
package internal
import (
"encoding/xml"
"fmt"
"io"
"reflect"
"strings"
)
// RawXMLValue is a raw XML value. It implements xml.Unmarshaler and
// xml.Marshaler and can be used to delay XML decoding or precompute an XML
// encoding.
type RawXMLValue struct {
tok xml.Token // guaranteed not to be xml.EndElement
children []RawXMLValue
// Unfortunately encoding/xml doesn't offer TokenWriter, so we need to
// cache outgoing data.
out interface{}
}
// NewRawXMLElement creates a new RawXMLValue for an element.
func NewRawXMLElement(name xml.Name, attr []xml.Attr, children []RawXMLValue) *RawXMLValue {
return &RawXMLValue{tok: xml.StartElement{name, attr}, children: children}
}
// EncodeRawXMLElement encodes a value into a new RawXMLValue. The XML value
// can only be used for marshalling.
func EncodeRawXMLElement(v interface{}) (*RawXMLValue, error) {
return &RawXMLValue{out: v}, nil
}
// UnmarshalXML implements xml.Unmarshaler.
func (val *RawXMLValue) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
val.tok = start
val.children = nil
val.out = nil
for {
tok, err := d.Token()
if err != nil {
return err
}
switch tok := tok.(type) {
case xml.StartElement:
child := RawXMLValue{}
if err := child.UnmarshalXML(d, tok); err != nil {
return err
}
val.children = append(val.children, child)
case xml.EndElement:
return nil
default:
val.children = append(val.children, RawXMLValue{tok: xml.CopyToken(tok)})
}
}
}
// MarshalXML implements xml.Marshaler.
func (val *RawXMLValue) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if val.out != nil {
return e.Encode(val.out)
}
switch tok := val.tok.(type) {
case xml.StartElement:
if err := e.EncodeToken(tok); err != nil {
return err
}
for _, child := range val.children {
// TODO: find a sensible value for the start argument?
if err := child.MarshalXML(e, xml.StartElement{}); err != nil {
return err
}
}
return e.EncodeToken(tok.End())
case xml.EndElement:
panic("unexpected end element")
default:
return e.EncodeToken(tok)
}
}
var _ xml.Marshaler = (*RawXMLValue)(nil)
var _ xml.Unmarshaler = (*RawXMLValue)(nil)
func (val *RawXMLValue) Decode(v interface{}) error {
return xml.NewTokenDecoder(val.TokenReader()).Decode(&v)
}
func (val *RawXMLValue) XMLName() (name xml.Name, ok bool) {
if start, ok := val.tok.(xml.StartElement); ok {
return start.Name, true
}
return xml.Name{}, false
}
// TokenReader returns a stream of tokens for the XML value.
func (val *RawXMLValue) TokenReader() xml.TokenReader {
if val.out != nil {
panic("webdav: called RawXMLValue.TokenReader on a marshal-only XML value")
}
return &rawXMLValueReader{val: val}
}
type rawXMLValueReader struct {
val *RawXMLValue
start, end bool
child int
childReader xml.TokenReader
}
func (tr *rawXMLValueReader) Token() (xml.Token, error) {
if tr.end {
return nil, io.EOF
}
start, ok := tr.val.tok.(xml.StartElement)
if !ok {
tr.end = true
return tr.val.tok, nil
}
if !tr.start {
tr.start = true
return start, nil
}
for tr.child < len(tr.val.children) {
if tr.childReader == nil {
tr.childReader = tr.val.children[tr.child].TokenReader()
}
tok, err := tr.childReader.Token()
if err == io.EOF {
tr.childReader = nil
tr.child++
} else {
return tok, err
}
}
tr.end = true
return start.End(), nil
}
var _ xml.TokenReader = (*rawXMLValueReader)(nil)
func valueXMLName(v interface{}) (xml.Name, error) {
t := reflect.TypeOf(v)
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return xml.Name{}, fmt.Errorf("webdav: %T is not a struct", v)
}
nameField, ok := t.FieldByName("XMLName")
if !ok {
return xml.Name{}, fmt.Errorf("webdav: %T is missing an XMLName struct field", v)
}
if nameField.Type != reflect.TypeOf(xml.Name{}) {
return xml.Name{}, fmt.Errorf("webdav: %T.XMLName isn't an xml.Name", v)
}
tag := nameField.Tag.Get("xml")
if tag == "" {
return xml.Name{}, fmt.Errorf(`webdav: %T.XMLName is missing an "xml" tag`, v)
}
name := strings.Split(tag, ",")[0]
nameParts := strings.Split(name, " ")
if len(nameParts) != 2 {
return xml.Name{}, fmt.Errorf("webdav: expected a namespace and local name in %T.XMLName's xml tag", v)
}
return xml.Name{nameParts[0], nameParts[1]}, nil
}

View File

@ -1,74 +0,0 @@
package internal
import (
"bytes"
"encoding/xml"
"io"
"testing"
)
const rawXML = `<?xml version="1.0" encoding="UTF-8"?>
<bookstore>
<book category="COOKING">
<title lang="en">Everyday Italian</title>
<author>Giada De Laurentiis</author>
<year>2005</year>
</book>
<book category="CHILDREN">
<title lang="en">Harry Potter</title>
<author>J K. Rowling</author>
<year>2005</year>
</book>
</bookstore>`
func TestRawXMLValue(t *testing.T) {
// TODO: test XML namespaces too
var rawValue RawXMLValue
if err := xml.Unmarshal([]byte(rawXML), &rawValue); err != nil {
t.Fatalf("xml.Unmarshal() = %v", err)
}
b, err := xml.Marshal(&rawValue)
if err != nil {
t.Fatalf("xml.Marshal() = %v", err)
}
s := xml.Header + string(b)
if s != rawXML {
t.Errorf("input doesn't match output:\n%v\nvs.\n%v", rawXML, s)
}
}
func TestRawXMLValue_TokenReader(t *testing.T) {
var rawValue RawXMLValue
if err := xml.Unmarshal([]byte(rawXML), &rawValue); err != nil {
t.Fatalf("xml.Unmarshal() = %v", err)
}
tr := rawValue.TokenReader()
var buf bytes.Buffer
enc := xml.NewEncoder(&buf)
for {
tok, err := tr.Token()
if err == io.EOF {
break
} else if err != nil {
t.Fatalf("TokenReader.Token() = %v", err)
}
if err := enc.EncodeToken(tok); err != nil {
t.Fatalf("Encoder.EncodeToken() = %v", err)
}
}
if err := enc.Flush(); err != nil {
t.Fatalf("Encoder.Flush() = %v", err)
}
s := xml.Header + buf.String()
if s != rawXML {
t.Errorf("input doesn't match output:\n%v\nvs.\n%v", rawXML, s)
}
}

94
litmus_test_server.go Normal file
View File

@ -0,0 +1,94 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build ignore
/*
This program is a server for the WebDAV 'litmus' compliance test at
http://www.webdav.org/neon/litmus/
To run the test:
go run litmus_test_server.go
and separately, from the downloaded litmus-xxx directory:
make URL=http://localhost:9999/ check
*/
package main
import (
"flag"
"fmt"
"log"
"net/http"
"net/url"
"golang.org/x/net/webdav"
)
var port = flag.Int("port", 9999, "server port")
func main() {
flag.Parse()
log.SetFlags(0)
h := &webdav.Handler{
FileSystem: webdav.NewMemFS(),
LockSystem: webdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
litmus := r.Header.Get("X-Litmus")
if len(litmus) > 19 {
litmus = litmus[:16] + "..."
}
switch r.Method {
case "COPY", "MOVE":
dst := ""
if u, err := url.Parse(r.Header.Get("Destination")); err == nil {
dst = u.Path
}
o := r.Header.Get("Overwrite")
log.Printf("%-20s%-10s%-30s%-30so=%-2s%v", litmus, r.Method, r.URL.Path, dst, o, err)
default:
log.Printf("%-20s%-10s%-30s%v", litmus, r.Method, r.URL.Path, err)
}
},
}
// The next line would normally be:
// http.Handle("/", h)
// but we wrap that HTTP handler h to cater for a special case.
//
// The propfind_invalid2 litmus test case expects an empty namespace prefix
// declaration to be an error. The FAQ in the webdav litmus test says:
//
// "What does the "propfind_invalid2" test check for?...
//
// If a request was sent with an XML body which included an empty namespace
// prefix declaration (xmlns:ns1=""), then the server must reject that with
// a "400 Bad Request" response, as it is invalid according to the XML
// Namespace specification."
//
// On the other hand, the Go standard library's encoding/xml package
// accepts an empty xmlns namespace, as per the discussion at
// https://github.com/golang/go/issues/8068
//
// Empty namespaces seem disallowed in the second (2006) edition of the XML
// standard, but allowed in a later edition. The grammar differs between
// http://www.w3.org/TR/2006/REC-xml-names-20060816/#ns-decl and
// http://www.w3.org/TR/REC-xml-names/#dt-prefix
//
// Thus, we assume that the propfind_invalid2 test is obsolete, and
// hard-code the 400 Bad Request response that the test expects.
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Litmus") == "props: 3 (propfind_invalid2)" {
http.Error(w, "400 Bad Request", http.StatusBadRequest)
return
}
h.ServeHTTP(w, r)
}))
addr := fmt.Sprintf(":%d", *port)
log.Printf("Serving %v", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}

445
lock.go Normal file
View File

@ -0,0 +1,445 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"container/heap"
"errors"
"strconv"
"strings"
"sync"
"time"
)
var (
// ErrConfirmationFailed is returned by a LockSystem's Confirm method.
ErrConfirmationFailed = errors.New("webdav: confirmation failed")
// ErrForbidden is returned by a LockSystem's Unlock method.
ErrForbidden = errors.New("webdav: forbidden")
// ErrLocked is returned by a LockSystem's Create, Refresh and Unlock methods.
ErrLocked = errors.New("webdav: locked")
// ErrNoSuchLock is returned by a LockSystem's Refresh and Unlock methods.
ErrNoSuchLock = errors.New("webdav: no such lock")
)
// Condition can match a WebDAV resource, based on a token or ETag.
// Exactly one of Token and ETag should be non-empty.
type Condition struct {
Not bool
Token string
ETag string
}
// LockSystem manages access to a collection of named resources. The elements
// in a lock name are separated by slash ('/', U+002F) characters, regardless
// of host operating system convention.
type LockSystem interface {
// Confirm confirms that the caller can claim all of the locks specified by
// the given conditions, and that holding the union of all of those locks
// gives exclusive access to all of the named resources. Up to two resources
// can be named. Empty names are ignored.
//
// Exactly one of release and err will be non-nil. If release is non-nil,
// all of the requested locks are held until release is called. Calling
// release does not unlock the lock, in the WebDAV UNLOCK sense, but once
// Confirm has confirmed that a lock claim is valid, that lock cannot be
// Confirmed again until it has been released.
//
// If Confirm returns ErrConfirmationFailed then the Handler will continue
// to try any other set of locks presented (a WebDAV HTTP request can
// present more than one set of locks). If it returns any other non-nil
// error, the Handler will write a "500 Internal Server Error" HTTP status.
Confirm(now time.Time, name0, name1 string, conditions ...Condition) (release func(), err error)
// Create creates a lock with the given depth, duration, owner and root
// (name). The depth will either be negative (meaning infinite) or zero.
//
// If Create returns ErrLocked then the Handler will write a "423 Locked"
// HTTP status. If it returns any other non-nil error, the Handler will
// write a "500 Internal Server Error" HTTP status.
//
// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10.6 for
// when to use each error.
//
// The token returned identifies the created lock. It should be an absolute
// URI as defined by RFC 3986, Section 4.3. In particular, it should not
// contain whitespace.
Create(now time.Time, details LockDetails) (token string, err error)
// Refresh refreshes the lock with the given token.
//
// If Refresh returns ErrLocked then the Handler will write a "423 Locked"
// HTTP Status. If Refresh returns ErrNoSuchLock then the Handler will write
// a "412 Precondition Failed" HTTP Status. If it returns any other non-nil
// error, the Handler will write a "500 Internal Server Error" HTTP status.
//
// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10.6 for
// when to use each error.
Refresh(now time.Time, token string, duration time.Duration) (LockDetails, error)
// Unlock unlocks the lock with the given token.
//
// If Unlock returns ErrForbidden then the Handler will write a "403
// Forbidden" HTTP Status. If Unlock returns ErrLocked then the Handler
// will write a "423 Locked" HTTP status. If Unlock returns ErrNoSuchLock
// then the Handler will write a "409 Conflict" HTTP Status. If it returns
// any other non-nil error, the Handler will write a "500 Internal Server
// Error" HTTP status.
//
// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.11.1 for
// when to use each error.
Unlock(now time.Time, token string) error
}
// LockDetails are a lock's metadata.
type LockDetails struct {
// Root is the root resource name being locked. For a zero-depth lock, the
// root is the only resource being locked.
Root string
// Duration is the lock timeout. A negative duration means infinite.
Duration time.Duration
// OwnerXML is the verbatim <owner> XML given in a LOCK HTTP request.
//
// TODO: does the "verbatim" nature play well with XML namespaces?
// Does the OwnerXML field need to have more structure? See
// https://codereview.appspot.com/175140043/#msg2
OwnerXML string
// ZeroDepth is whether the lock has zero depth. If it does not have zero
// depth, it has infinite depth.
ZeroDepth bool
}
// NewMemLS returns a new in-memory LockSystem.
func NewMemLS() LockSystem {
return &memLS{
byName: make(map[string]*memLSNode),
byToken: make(map[string]*memLSNode),
gen: uint64(time.Now().Unix()),
}
}
type memLS struct {
mu sync.Mutex
byName map[string]*memLSNode
byToken map[string]*memLSNode
gen uint64
// byExpiry only contains those nodes whose LockDetails have a finite
// Duration and are yet to expire.
byExpiry byExpiry
}
func (m *memLS) nextToken() string {
m.gen++
return strconv.FormatUint(m.gen, 10)
}
func (m *memLS) collectExpiredNodes(now time.Time) {
for len(m.byExpiry) > 0 {
if now.Before(m.byExpiry[0].expiry) {
break
}
m.remove(m.byExpiry[0])
}
}
func (m *memLS) Confirm(now time.Time, name0, name1 string, conditions ...Condition) (func(), error) {
m.mu.Lock()
defer m.mu.Unlock()
m.collectExpiredNodes(now)
var n0, n1 *memLSNode
if name0 != "" {
if n0 = m.lookup(slashClean(name0), conditions...); n0 == nil {
return nil, ErrConfirmationFailed
}
}
if name1 != "" {
if n1 = m.lookup(slashClean(name1), conditions...); n1 == nil {
return nil, ErrConfirmationFailed
}
}
// Don't hold the same node twice.
if n1 == n0 {
n1 = nil
}
if n0 != nil {
m.hold(n0)
}
if n1 != nil {
m.hold(n1)
}
return func() {
m.mu.Lock()
defer m.mu.Unlock()
if n1 != nil {
m.unhold(n1)
}
if n0 != nil {
m.unhold(n0)
}
}, nil
}
// lookup returns the node n that locks the named resource, provided that n
// matches at least one of the given conditions and that lock isn't held by
// another party. Otherwise, it returns nil.
//
// n may be a parent of the named resource, if n is an infinite depth lock.
func (m *memLS) lookup(name string, conditions ...Condition) (n *memLSNode) {
// TODO: support Condition.Not and Condition.ETag.
for _, c := range conditions {
n = m.byToken[c.Token]
if n == nil || n.held {
continue
}
if name == n.details.Root {
return n
}
if n.details.ZeroDepth {
continue
}
if n.details.Root == "/" || strings.HasPrefix(name, n.details.Root+"/") {
return n
}
}
return nil
}
func (m *memLS) hold(n *memLSNode) {
if n.held {
panic("webdav: memLS inconsistent held state")
}
n.held = true
if n.details.Duration >= 0 && n.byExpiryIndex >= 0 {
heap.Remove(&m.byExpiry, n.byExpiryIndex)
}
}
func (m *memLS) unhold(n *memLSNode) {
if !n.held {
panic("webdav: memLS inconsistent held state")
}
n.held = false
if n.details.Duration >= 0 {
heap.Push(&m.byExpiry, n)
}
}
func (m *memLS) Create(now time.Time, details LockDetails) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.collectExpiredNodes(now)
details.Root = slashClean(details.Root)
if !m.canCreate(details.Root, details.ZeroDepth) {
return "", ErrLocked
}
n := m.create(details.Root)
n.token = m.nextToken()
m.byToken[n.token] = n
n.details = details
if n.details.Duration >= 0 {
n.expiry = now.Add(n.details.Duration)
heap.Push(&m.byExpiry, n)
}
return n.token, nil
}
func (m *memLS) Refresh(now time.Time, token string, duration time.Duration) (LockDetails, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.collectExpiredNodes(now)
n := m.byToken[token]
if n == nil {
return LockDetails{}, ErrNoSuchLock
}
if n.held {
return LockDetails{}, ErrLocked
}
if n.byExpiryIndex >= 0 {
heap.Remove(&m.byExpiry, n.byExpiryIndex)
}
n.details.Duration = duration
if n.details.Duration >= 0 {
n.expiry = now.Add(n.details.Duration)
heap.Push(&m.byExpiry, n)
}
return n.details, nil
}
func (m *memLS) Unlock(now time.Time, token string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.collectExpiredNodes(now)
n := m.byToken[token]
if n == nil {
return ErrNoSuchLock
}
if n.held {
return ErrLocked
}
m.remove(n)
return nil
}
func (m *memLS) canCreate(name string, zeroDepth bool) bool {
return walkToRoot(name, func(name0 string, first bool) bool {
n := m.byName[name0]
if n == nil {
return true
}
if first {
if n.token != "" {
// The target node is already locked.
return false
}
if !zeroDepth {
// The requested lock depth is infinite, and the fact that n exists
// (n != nil) means that a descendent of the target node is locked.
return false
}
} else if n.token != "" && !n.details.ZeroDepth {
// An ancestor of the target node is locked with infinite depth.
return false
}
return true
})
}
func (m *memLS) create(name string) (ret *memLSNode) {
walkToRoot(name, func(name0 string, first bool) bool {
n := m.byName[name0]
if n == nil {
n = &memLSNode{
details: LockDetails{
Root: name0,
},
byExpiryIndex: -1,
}
m.byName[name0] = n
}
n.refCount++
if first {
ret = n
}
return true
})
return ret
}
func (m *memLS) remove(n *memLSNode) {
delete(m.byToken, n.token)
n.token = ""
walkToRoot(n.details.Root, func(name0 string, first bool) bool {
x := m.byName[name0]
x.refCount--
if x.refCount == 0 {
delete(m.byName, name0)
}
return true
})
if n.byExpiryIndex >= 0 {
heap.Remove(&m.byExpiry, n.byExpiryIndex)
}
}
func walkToRoot(name string, f func(name0 string, first bool) bool) bool {
for first := true; ; first = false {
if !f(name, first) {
return false
}
if name == "/" {
break
}
name = name[:strings.LastIndex(name, "/")]
if name == "" {
name = "/"
}
}
return true
}
type memLSNode struct {
// details are the lock metadata. Even if this node's name is not explicitly locked,
// details.Root will still equal the node's name.
details LockDetails
// token is the unique identifier for this node's lock. An empty token means that
// this node is not explicitly locked.
token string
// refCount is the number of self-or-descendent nodes that are explicitly locked.
refCount int
// expiry is when this node's lock expires.
expiry time.Time
// byExpiryIndex is the index of this node in memLS.byExpiry. It is -1
// if this node does not expire, or has expired.
byExpiryIndex int
// held is whether this node's lock is actively held by a Confirm call.
held bool
}
type byExpiry []*memLSNode
func (b *byExpiry) Len() int {
return len(*b)
}
func (b *byExpiry) Less(i, j int) bool {
return (*b)[i].expiry.Before((*b)[j].expiry)
}
func (b *byExpiry) Swap(i, j int) {
(*b)[i], (*b)[j] = (*b)[j], (*b)[i]
(*b)[i].byExpiryIndex = i
(*b)[j].byExpiryIndex = j
}
func (b *byExpiry) Push(x interface{}) {
n := x.(*memLSNode)
n.byExpiryIndex = len(*b)
*b = append(*b, n)
}
func (b *byExpiry) Pop() interface{} {
i := len(*b) - 1
n := (*b)[i]
(*b)[i] = nil
n.byExpiryIndex = -1
*b = (*b)[:i]
return n
}
const infiniteTimeout = -1
// parseTimeout parses the Timeout HTTP header, as per section 10.7. If s is
// empty, an infiniteTimeout is returned.
func parseTimeout(s string) (time.Duration, error) {
if s == "" {
return infiniteTimeout, nil
}
if i := strings.IndexByte(s, ','); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
if s == "Infinite" {
return infiniteTimeout, nil
}
const pre = "Second-"
if !strings.HasPrefix(s, pre) {
return 0, errInvalidTimeout
}
s = s[len(pre):]
if s == "" || s[0] < '0' || '9' < s[0] {
return 0, errInvalidTimeout
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || 1<<32-1 < n {
return 0, errInvalidTimeout
}
return time.Duration(n) * time.Second, nil
}

731
lock_test.go Normal file
View File

@ -0,0 +1,731 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"fmt"
"math/rand"
"path"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"time"
)
func TestWalkToRoot(t *testing.T) {
testCases := []struct {
name string
want []string
}{{
"/a/b/c/d",
[]string{
"/a/b/c/d",
"/a/b/c",
"/a/b",
"/a",
"/",
},
}, {
"/a",
[]string{
"/a",
"/",
},
}, {
"/",
[]string{
"/",
},
}}
for _, tc := range testCases {
var got []string
if !walkToRoot(tc.name, func(name0 string, first bool) bool {
if first != (len(got) == 0) {
t.Errorf("name=%q: first=%t but len(got)==%d", tc.name, first, len(got))
return false
}
got = append(got, name0)
return true
}) {
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("name=%q:\ngot %q\nwant %q", tc.name, got, tc.want)
}
}
}
var lockTestDurations = []time.Duration{
infiniteTimeout, // infiniteTimeout means to never expire.
0, // A zero duration means to expire immediately.
100 * time.Hour, // A very large duration will not expire in these tests.
}
// lockTestNames are the names of a set of mutually compatible locks. For each
// name fragment:
// - _ means no explicit lock.
// - i means a infinite-depth lock,
// - z means a zero-depth lock,
var lockTestNames = []string{
"/_/_/_/_/z",
"/_/_/i",
"/_/z",
"/_/z/i",
"/_/z/z",
"/_/z/_/i",
"/_/z/_/z",
"/i",
"/z",
"/z/_/i",
"/z/_/z",
}
func lockTestZeroDepth(name string) bool {
switch name[len(name)-1] {
case 'i':
return false
case 'z':
return true
}
panic(fmt.Sprintf("lock name %q did not end with 'i' or 'z'", name))
}
func TestMemLSCanCreate(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
for _, name := range lockTestNames {
_, err := m.Create(now, LockDetails{
Root: name,
Duration: infiniteTimeout,
ZeroDepth: lockTestZeroDepth(name),
})
if err != nil {
t.Fatalf("creating lock for %q: %v", name, err)
}
}
wantCanCreate := func(name string, zeroDepth bool) bool {
for _, n := range lockTestNames {
switch {
case n == name:
// An existing lock has the same name as the proposed lock.
return false
case strings.HasPrefix(n, name):
// An existing lock would be a child of the proposed lock,
// which conflicts if the proposed lock has infinite depth.
if !zeroDepth {
return false
}
case strings.HasPrefix(name, n):
// An existing lock would be an ancestor of the proposed lock,
// which conflicts if the ancestor has infinite depth.
if n[len(n)-1] == 'i' {
return false
}
}
}
return true
}
var check func(int, string)
check = func(recursion int, name string) {
for _, zeroDepth := range []bool{false, true} {
got := m.canCreate(name, zeroDepth)
want := wantCanCreate(name, zeroDepth)
if got != want {
t.Errorf("canCreate name=%q zeroDepth=%t: got %t, want %t", name, zeroDepth, got, want)
}
}
if recursion == 6 {
return
}
if name != "/" {
name += "/"
}
for _, c := range "_iz" {
check(recursion+1, name+string(c))
}
}
check(0, "/")
}
func TestMemLSLookup(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
badToken := m.nextToken()
t.Logf("badToken=%q", badToken)
for _, name := range lockTestNames {
token, err := m.Create(now, LockDetails{
Root: name,
Duration: infiniteTimeout,
ZeroDepth: lockTestZeroDepth(name),
})
if err != nil {
t.Fatalf("creating lock for %q: %v", name, err)
}
t.Logf("%-15q -> node=%p token=%q", name, m.byName[name], token)
}
baseNames := append([]string{"/a", "/b/c"}, lockTestNames...)
for _, baseName := range baseNames {
for _, suffix := range []string{"", "/0", "/1/2/3"} {
name := baseName + suffix
goodToken := ""
base := m.byName[baseName]
if base != nil && (suffix == "" || !lockTestZeroDepth(baseName)) {
goodToken = base.token
}
for _, token := range []string{badToken, goodToken} {
if token == "" {
continue
}
got := m.lookup(name, Condition{Token: token})
want := base
if token == badToken {
want = nil
}
if got != want {
t.Errorf("name=%-20qtoken=%q (bad=%t): got %p, want %p",
name, token, token == badToken, got, want)
}
}
}
}
}
func TestMemLSConfirm(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
alice, err := m.Create(now, LockDetails{
Root: "/alice",
Duration: infiniteTimeout,
ZeroDepth: false,
})
tweedle, err := m.Create(now, LockDetails{
Root: "/tweedle",
Duration: infiniteTimeout,
ZeroDepth: false,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Create: inconsistent state: %v", err)
}
// Test a mismatch between name and condition.
_, err = m.Confirm(now, "/tweedle/dee", "", Condition{Token: alice})
if err != ErrConfirmationFailed {
t.Fatalf("Confirm (mismatch): got %v, want ErrConfirmationFailed", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Confirm (mismatch): inconsistent state: %v", err)
}
// Test two names (that fall under the same lock) in the one Confirm call.
release, err := m.Confirm(now, "/tweedle/dee", "/tweedle/dum", Condition{Token: tweedle})
if err != nil {
t.Fatalf("Confirm (twins): %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Confirm (twins): inconsistent state: %v", err)
}
release()
if err := m.consistent(); err != nil {
t.Fatalf("release (twins): inconsistent state: %v", err)
}
// Test the same two names in overlapping Confirm / release calls.
releaseDee, err := m.Confirm(now, "/tweedle/dee", "", Condition{Token: tweedle})
if err != nil {
t.Fatalf("Confirm (sequence #0): %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Confirm (sequence #0): inconsistent state: %v", err)
}
_, err = m.Confirm(now, "/tweedle/dum", "", Condition{Token: tweedle})
if err != ErrConfirmationFailed {
t.Fatalf("Confirm (sequence #1): got %v, want ErrConfirmationFailed", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Confirm (sequence #1): inconsistent state: %v", err)
}
releaseDee()
if err := m.consistent(); err != nil {
t.Fatalf("release (sequence #2): inconsistent state: %v", err)
}
releaseDum, err := m.Confirm(now, "/tweedle/dum", "", Condition{Token: tweedle})
if err != nil {
t.Fatalf("Confirm (sequence #3): %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Confirm (sequence #3): inconsistent state: %v", err)
}
// Test that you can't unlock a held lock.
err = m.Unlock(now, tweedle)
if err != ErrLocked {
t.Fatalf("Unlock (sequence #4): got %v, want ErrLocked", err)
}
releaseDum()
if err := m.consistent(); err != nil {
t.Fatalf("release (sequence #5): inconsistent state: %v", err)
}
err = m.Unlock(now, tweedle)
if err != nil {
t.Fatalf("Unlock (sequence #6): %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Unlock (sequence #6): inconsistent state: %v", err)
}
}
func TestMemLSNonCanonicalRoot(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
token, err := m.Create(now, LockDetails{
Root: "/foo/./bar//",
Duration: 1 * time.Second,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Create: inconsistent state: %v", err)
}
if err := m.Unlock(now, token); err != nil {
t.Fatalf("Unlock: %v", err)
}
if err := m.consistent(); err != nil {
t.Fatalf("Unlock: inconsistent state: %v", err)
}
}
func TestMemLSExpiry(t *testing.T) {
m := NewMemLS().(*memLS)
testCases := []string{
"setNow 0",
"create /a.5",
"want /a.5",
"create /c.6",
"want /a.5 /c.6",
"create /a/b.7",
"want /a.5 /a/b.7 /c.6",
"setNow 4",
"want /a.5 /a/b.7 /c.6",
"setNow 5",
"want /a/b.7 /c.6",
"setNow 6",
"want /a/b.7",
"setNow 7",
"want ",
"setNow 8",
"want ",
"create /a.12",
"create /b.13",
"create /c.15",
"create /a/d.16",
"want /a.12 /a/d.16 /b.13 /c.15",
"refresh /a.14",
"want /a.14 /a/d.16 /b.13 /c.15",
"setNow 12",
"want /a.14 /a/d.16 /b.13 /c.15",
"setNow 13",
"want /a.14 /a/d.16 /c.15",
"setNow 14",
"want /a/d.16 /c.15",
"refresh /a/d.20",
"refresh /c.20",
"want /a/d.20 /c.20",
"setNow 20",
"want ",
}
tokens := map[string]string{}
zTime := time.Unix(0, 0)
now := zTime
for i, tc := range testCases {
j := strings.IndexByte(tc, ' ')
if j < 0 {
t.Fatalf("test case #%d %q: invalid command", i, tc)
}
op, arg := tc[:j], tc[j+1:]
switch op {
default:
t.Fatalf("test case #%d %q: invalid operation %q", i, tc, op)
case "create", "refresh":
parts := strings.Split(arg, ".")
if len(parts) != 2 {
t.Fatalf("test case #%d %q: invalid create", i, tc)
}
root := parts[0]
d, err := strconv.Atoi(parts[1])
if err != nil {
t.Fatalf("test case #%d %q: invalid duration", i, tc)
}
dur := time.Unix(0, 0).Add(time.Duration(d) * time.Second).Sub(now)
switch op {
case "create":
token, err := m.Create(now, LockDetails{
Root: root,
Duration: dur,
ZeroDepth: true,
})
if err != nil {
t.Fatalf("test case #%d %q: Create: %v", i, tc, err)
}
tokens[root] = token
case "refresh":
token := tokens[root]
if token == "" {
t.Fatalf("test case #%d %q: no token for %q", i, tc, root)
}
got, err := m.Refresh(now, token, dur)
if err != nil {
t.Fatalf("test case #%d %q: Refresh: %v", i, tc, err)
}
want := LockDetails{
Root: root,
Duration: dur,
ZeroDepth: true,
}
if got != want {
t.Fatalf("test case #%d %q:\ngot %v\nwant %v", i, tc, got, want)
}
}
case "setNow":
d, err := strconv.Atoi(arg)
if err != nil {
t.Fatalf("test case #%d %q: invalid duration", i, tc)
}
now = time.Unix(0, 0).Add(time.Duration(d) * time.Second)
case "want":
m.mu.Lock()
m.collectExpiredNodes(now)
got := make([]string, 0, len(m.byToken))
for _, n := range m.byToken {
got = append(got, fmt.Sprintf("%s.%d",
n.details.Root, n.expiry.Sub(zTime)/time.Second))
}
m.mu.Unlock()
sort.Strings(got)
want := []string{}
if arg != "" {
want = strings.Split(arg, " ")
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("test case #%d %q:\ngot %q\nwant %q", i, tc, got, want)
}
}
if err := m.consistent(); err != nil {
t.Fatalf("test case #%d %q: inconsistent state: %v", i, tc, err)
}
}
}
func TestMemLS(t *testing.T) {
now := time.Unix(0, 0)
m := NewMemLS().(*memLS)
rng := rand.New(rand.NewSource(0))
tokens := map[string]string{}
nConfirm, nCreate, nRefresh, nUnlock := 0, 0, 0, 0
const N = 2000
for i := 0; i < N; i++ {
name := lockTestNames[rng.Intn(len(lockTestNames))]
duration := lockTestDurations[rng.Intn(len(lockTestDurations))]
confirmed, unlocked := false, false
// If the name was already locked, we randomly confirm/release, refresh
// or unlock it. Otherwise, we create a lock.
token := tokens[name]
if token != "" {
switch rng.Intn(3) {
case 0:
confirmed = true
nConfirm++
release, err := m.Confirm(now, name, "", Condition{Token: token})
if err != nil {
t.Fatalf("iteration #%d: Confirm %q: %v", i, name, err)
}
if err := m.consistent(); err != nil {
t.Fatalf("iteration #%d: inconsistent state: %v", i, err)
}
release()
case 1:
nRefresh++
if _, err := m.Refresh(now, token, duration); err != nil {
t.Fatalf("iteration #%d: Refresh %q: %v", i, name, err)
}
case 2:
unlocked = true
nUnlock++
if err := m.Unlock(now, token); err != nil {
t.Fatalf("iteration #%d: Unlock %q: %v", i, name, err)
}
}
} else {
nCreate++
var err error
token, err = m.Create(now, LockDetails{
Root: name,
Duration: duration,
ZeroDepth: lockTestZeroDepth(name),
})
if err != nil {
t.Fatalf("iteration #%d: Create %q: %v", i, name, err)
}
}
if !confirmed {
if duration == 0 || unlocked {
// A zero-duration lock should expire immediately and is
// effectively equivalent to being unlocked.
tokens[name] = ""
} else {
tokens[name] = token
}
}
if err := m.consistent(); err != nil {
t.Fatalf("iteration #%d: inconsistent state: %v", i, err)
}
}
if nConfirm < N/10 {
t.Fatalf("too few Confirm calls: got %d, want >= %d", nConfirm, N/10)
}
if nCreate < N/10 {
t.Fatalf("too few Create calls: got %d, want >= %d", nCreate, N/10)
}
if nRefresh < N/10 {
t.Fatalf("too few Refresh calls: got %d, want >= %d", nRefresh, N/10)
}
if nUnlock < N/10 {
t.Fatalf("too few Unlock calls: got %d, want >= %d", nUnlock, N/10)
}
}
func (m *memLS) consistent() error {
m.mu.Lock()
defer m.mu.Unlock()
// If m.byName is non-empty, then it must contain an entry for the root "/",
// and its refCount should equal the number of locked nodes.
if len(m.byName) > 0 {
n := m.byName["/"]
if n == nil {
return fmt.Errorf(`non-empty m.byName does not contain the root "/"`)
}
if n.refCount != len(m.byToken) {
return fmt.Errorf("root node refCount=%d, differs from len(m.byToken)=%d", n.refCount, len(m.byToken))
}
}
for name, n := range m.byName {
// The map keys should be consistent with the node's copy of the key.
if n.details.Root != name {
return fmt.Errorf("node name %q != byName map key %q", n.details.Root, name)
}
// A name must be clean, and start with a "/".
if len(name) == 0 || name[0] != '/' {
return fmt.Errorf(`node name %q does not start with "/"`, name)
}
if name != path.Clean(name) {
return fmt.Errorf(`node name %q is not clean`, name)
}
// A node's refCount should be positive.
if n.refCount <= 0 {
return fmt.Errorf("non-positive refCount for node at name %q", name)
}
// A node's refCount should be the number of self-or-descendents that
// are locked (i.e. have a non-empty token).
var list []string
for name0, n0 := range m.byName {
// All of lockTestNames' name fragments are one byte long: '_', 'i' or 'z',
// so strings.HasPrefix is equivalent to self-or-descendent name match.
// We don't have to worry about "/foo/bar" being a false positive match
// for "/foo/b".
if strings.HasPrefix(name0, name) && n0.token != "" {
list = append(list, name0)
}
}
if n.refCount != len(list) {
sort.Strings(list)
return fmt.Errorf("node at name %q has refCount %d but locked self-or-descendents are %q (len=%d)",
name, n.refCount, list, len(list))
}
// A node n is in m.byToken if it has a non-empty token.
if n.token != "" {
if _, ok := m.byToken[n.token]; !ok {
return fmt.Errorf("node at name %q has token %q but not in m.byToken", name, n.token)
}
}
// A node n is in m.byExpiry if it has a non-negative byExpiryIndex.
if n.byExpiryIndex >= 0 {
if n.byExpiryIndex >= len(m.byExpiry) {
return fmt.Errorf("node at name %q has byExpiryIndex %d but m.byExpiry has length %d", name, n.byExpiryIndex, len(m.byExpiry))
}
if n != m.byExpiry[n.byExpiryIndex] {
return fmt.Errorf("node at name %q has byExpiryIndex %d but that indexes a different node", name, n.byExpiryIndex)
}
}
}
for token, n := range m.byToken {
// The map keys should be consistent with the node's copy of the key.
if n.token != token {
return fmt.Errorf("node token %q != byToken map key %q", n.token, token)
}
// Every node in m.byToken is in m.byName.
if _, ok := m.byName[n.details.Root]; !ok {
return fmt.Errorf("node at name %q in m.byToken but not in m.byName", n.details.Root)
}
}
for i, n := range m.byExpiry {
// The slice indices should be consistent with the node's copy of the index.
if n.byExpiryIndex != i {
return fmt.Errorf("node byExpiryIndex %d != byExpiry slice index %d", n.byExpiryIndex, i)
}
// Every node in m.byExpiry is in m.byName.
if _, ok := m.byName[n.details.Root]; !ok {
return fmt.Errorf("node at name %q in m.byExpiry but not in m.byName", n.details.Root)
}
// No node in m.byExpiry should be held.
if n.held {
return fmt.Errorf("node at name %q in m.byExpiry is held", n.details.Root)
}
}
return nil
}
func TestParseTimeout(t *testing.T) {
testCases := []struct {
s string
want time.Duration
wantErr error
}{{
"",
infiniteTimeout,
nil,
}, {
"Infinite",
infiniteTimeout,
nil,
}, {
"Infinitesimal",
0,
errInvalidTimeout,
}, {
"infinite",
0,
errInvalidTimeout,
}, {
"Second-0",
0 * time.Second,
nil,
}, {
"Second-123",
123 * time.Second,
nil,
}, {
" Second-456 ",
456 * time.Second,
nil,
}, {
"Second-4100000000",
4100000000 * time.Second,
nil,
}, {
"junk",
0,
errInvalidTimeout,
}, {
"Second-",
0,
errInvalidTimeout,
}, {
"Second--1",
0,
errInvalidTimeout,
}, {
"Second--123",
0,
errInvalidTimeout,
}, {
"Second-+123",
0,
errInvalidTimeout,
}, {
"Second-0x123",
0,
errInvalidTimeout,
}, {
"second-123",
0,
errInvalidTimeout,
}, {
"Second-4294967295",
4294967295 * time.Second,
nil,
}, {
// Section 10.7 says that "The timeout value for TimeType "Second"
// must not be greater than 2^32-1."
"Second-4294967296",
0,
errInvalidTimeout,
}, {
// This test case comes from section 9.10.9 of the spec. It says,
//
// "In this request, the client has specified that it desires an
// infinite-length lock, if available, otherwise a timeout of 4.1
// billion seconds, if available."
//
// The Go WebDAV package always supports infinite length locks,
// and ignores the fallback after the comma.
"Infinite, Second-4100000000",
infiniteTimeout,
nil,
}}
for _, tc := range testCases {
got, gotErr := parseTimeout(tc.s)
if got != tc.want || gotErr != tc.wantErr {
t.Errorf("parsing %q:\ngot %v, %v\nwant %v, %v", tc.s, got, gotErr, tc.want, tc.wantErr)
}
}
}

416
prop.go Normal file
View File

@ -0,0 +1,416 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"golang.org/x/net/context"
)
// Proppatch describes a property update instruction as defined in RFC 4918.
// See http://www.webdav.org/specs/rfc4918.html#METHOD_PROPPATCH
type Proppatch struct {
// Remove specifies whether this patch removes properties. If it does not
// remove them, it sets them.
Remove bool
// Props contains the properties to be set or removed.
Props []Property
}
// Propstat describes a XML propstat element as defined in RFC 4918.
// See http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat
type Propstat struct {
// Props contains the properties for which Status applies.
Props []Property
// Status defines the HTTP status code of the properties in Prop.
// Allowed values include, but are not limited to the WebDAV status
// code extensions for HTTP/1.1.
// http://www.webdav.org/specs/rfc4918.html#status.code.extensions.to.http11
Status int
// XMLError contains the XML representation of the optional error element.
// XML content within this field must not rely on any predefined
// namespace declarations or prefixes. If empty, the XML error element
// is omitted.
XMLError string
// ResponseDescription contains the contents of the optional
// responsedescription field. If empty, the XML element is omitted.
ResponseDescription string
}
// makePropstats returns a slice containing those of x and y whose Props slice
// is non-empty. If both are empty, it returns a slice containing an otherwise
// zero Propstat whose HTTP status code is 200 OK.
func makePropstats(x, y Propstat) []Propstat {
pstats := make([]Propstat, 0, 2)
if len(x.Props) != 0 {
pstats = append(pstats, x)
}
if len(y.Props) != 0 {
pstats = append(pstats, y)
}
if len(pstats) == 0 {
pstats = append(pstats, Propstat{
Status: http.StatusOK,
})
}
return pstats
}
// DeadPropsHolder holds the dead properties of a resource.
//
// Dead properties are those properties that are explicitly defined. In
// comparison, live properties, such as DAV:getcontentlength, are implicitly
// defined by the underlying resource, and cannot be explicitly overridden or
// removed. See the Terminology section of
// http://www.webdav.org/specs/rfc4918.html#rfc.section.3
//
// There is a whitelist of the names of live properties. This package handles
// all live properties, and will only pass non-whitelisted names to the Patch
// method of DeadPropsHolder implementations.
type DeadPropsHolder interface {
// DeadProps returns a copy of the dead properties held.
DeadProps() (map[xml.Name]Property, error)
// Patch patches the dead properties held.
//
// Patching is atomic; either all or no patches succeed. It returns (nil,
// non-nil) if an internal server error occurred, otherwise the Propstats
// collectively contain one Property for each proposed patch Property. If
// all patches succeed, Patch returns a slice of length one and a Propstat
// element with a 200 OK HTTP status code. If none succeed, for reasons
// other than an internal server error, no Propstat has status 200 OK.
//
// For more details on when various HTTP status codes apply, see
// http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status
Patch([]Proppatch) ([]Propstat, error)
}
// liveProps contains all supported, protected DAV: properties.
var liveProps = map[xml.Name]struct {
// findFn implements the propfind function of this property. If nil,
// it indicates a hidden property.
findFn func(context.Context, FileSystem, LockSystem, string, os.FileInfo) (string, error)
// dir is true if the property applies to directories.
dir bool
}{
{Space: "DAV:", Local: "resourcetype"}: {
findFn: findResourceType,
dir: true,
},
{Space: "DAV:", Local: "displayname"}: {
findFn: findDisplayName,
dir: true,
},
{Space: "DAV:", Local: "getcontentlength"}: {
findFn: findContentLength,
dir: false,
},
{Space: "DAV:", Local: "getlastmodified"}: {
findFn: findLastModified,
// http://webdav.org/specs/rfc4918.html#PROPERTY_getlastmodified
// suggests that getlastmodified should only apply to GETable
// resources, and this package does not support GET on directories.
//
// Nonetheless, some WebDAV clients expect child directories to be
// sortable by getlastmodified date, so this value is true, not false.
// See golang.org/issue/15334.
dir: true,
},
{Space: "DAV:", Local: "creationdate"}: {
findFn: nil,
dir: false,
},
{Space: "DAV:", Local: "getcontentlanguage"}: {
findFn: nil,
dir: false,
},
{Space: "DAV:", Local: "getcontenttype"}: {
findFn: findContentType,
dir: false,
},
{Space: "DAV:", Local: "getetag"}: {
findFn: findETag,
// findETag implements ETag as the concatenated hex values of a file's
// modification time and size. This is not a reliable synchronization
// mechanism for directories, so we do not advertise getetag for DAV
// collections.
dir: false,
},
// TODO: The lockdiscovery property requires LockSystem to list the
// active locks on a resource.
{Space: "DAV:", Local: "lockdiscovery"}: {},
{Space: "DAV:", Local: "supportedlock"}: {
findFn: findSupportedLock,
dir: true,
},
}
// Props returns the status of the properties named pnames for resource name.
//
// Each Propstat has a unique status and each property name will only be part
// of one Propstat element.
func Props(ctx context.Context, fs FileSystem, ls LockSystem, name string, pnames []xml.Name) ([]Propstat, error) {
f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
isDir := fi.IsDir()
var deadProps map[xml.Name]Property
if dph, ok := f.(DeadPropsHolder); ok {
deadProps, err = dph.DeadProps()
if err != nil {
return nil, err
}
}
pstatOK := Propstat{Status: http.StatusOK}
pstatNotFound := Propstat{Status: http.StatusNotFound}
for _, pn := range pnames {
// If this file has dead properties, check if they contain pn.
if dp, ok := deadProps[pn]; ok {
pstatOK.Props = append(pstatOK.Props, dp)
continue
}
// Otherwise, it must either be a live property or we don't know it.
if prop := liveProps[pn]; prop.findFn != nil && (prop.dir || !isDir) {
innerXML, err := prop.findFn(ctx, fs, ls, name, fi)
if err != nil {
return nil, err
}
pstatOK.Props = append(pstatOK.Props, Property{
XMLName: pn,
InnerXML: []byte(innerXML),
})
} else {
pstatNotFound.Props = append(pstatNotFound.Props, Property{
XMLName: pn,
})
}
}
return makePropstats(pstatOK, pstatNotFound), nil
}
// Propnames returns the property names defined for resource name.
func propnames(ctx context.Context, fs FileSystem, ls LockSystem, name string) ([]xml.Name, error) {
f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
isDir := fi.IsDir()
var deadProps map[xml.Name]Property
if dph, ok := f.(DeadPropsHolder); ok {
deadProps, err = dph.DeadProps()
if err != nil {
return nil, err
}
}
pnames := make([]xml.Name, 0, len(liveProps)+len(deadProps))
for pn, prop := range liveProps {
if prop.findFn != nil && (prop.dir || !isDir) {
pnames = append(pnames, pn)
}
}
for pn := range deadProps {
pnames = append(pnames, pn)
}
return pnames, nil
}
// Allprop returns the properties defined for resource name and the properties
// named in include.
//
// Note that RFC 4918 defines 'allprop' to return the DAV: properties defined
// within the RFC plus dead properties. Other live properties should only be
// returned if they are named in 'include'.
//
// See http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
func Allprop(ctx context.Context, fs FileSystem, ls LockSystem, name string, include []xml.Name) ([]Propstat, error) {
pnames, err := propnames(ctx, fs, ls, name)
if err != nil {
return nil, err
}
// Add names from include if they are not already covered in pnames.
nameset := make(map[xml.Name]bool)
for _, pn := range pnames {
nameset[pn] = true
}
for _, pn := range include {
if !nameset[pn] {
pnames = append(pnames, pn)
}
}
return Props(ctx, fs, ls, name, pnames)
}
// Patch patches the properties of resource name. The return values are
// constrained in the same manner as DeadPropsHolder.Patch.
func patch(ctx context.Context, fs FileSystem, ls LockSystem, name string, patches []Proppatch) ([]Propstat, error) {
conflict := false
loop:
for _, patch := range patches {
for _, p := range patch.Props {
if _, ok := liveProps[p.XMLName]; ok {
conflict = true
break loop
}
}
}
if conflict {
pstatForbidden := Propstat{
Status: http.StatusForbidden,
XMLError: `<D:cannot-modify-protected-property xmlns:D="DAV:"/>`,
}
pstatFailedDep := Propstat{
Status: StatusFailedDependency,
}
for _, patch := range patches {
for _, p := range patch.Props {
if _, ok := liveProps[p.XMLName]; ok {
pstatForbidden.Props = append(pstatForbidden.Props, Property{XMLName: p.XMLName})
} else {
pstatFailedDep.Props = append(pstatFailedDep.Props, Property{XMLName: p.XMLName})
}
}
}
return makePropstats(pstatForbidden, pstatFailedDep), nil
}
f, err := fs.OpenFile(ctx, name, os.O_RDWR, 0)
if err != nil {
return nil, err
}
defer f.Close()
if dph, ok := f.(DeadPropsHolder); ok {
ret, err := dph.Patch(patches)
if err != nil {
return nil, err
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat says that
// "The contents of the prop XML element must only list the names of
// properties to which the result in the status element applies."
for _, pstat := range ret {
for i, p := range pstat.Props {
pstat.Props[i] = Property{XMLName: p.XMLName}
}
}
return ret, nil
}
// The file doesn't implement the optional DeadPropsHolder interface, so
// all patches are forbidden.
pstat := Propstat{Status: http.StatusForbidden}
for _, patch := range patches {
for _, p := range patch.Props {
pstat.Props = append(pstat.Props, Property{XMLName: p.XMLName})
}
}
return []Propstat{pstat}, nil
}
func escapeXML(s string) string {
for i := 0; i < len(s); i++ {
// As an optimization, if s contains only ASCII letters, digits or a
// few special characters, the escaped value is s itself and we don't
// need to allocate a buffer and convert between string and []byte.
switch c := s[i]; {
case c == ' ' || c == '_' ||
('+' <= c && c <= '9') || // Digits as well as + , - . and /
('A' <= c && c <= 'Z') ||
('a' <= c && c <= 'z'):
continue
}
// Otherwise, go through the full escaping process.
var buf bytes.Buffer
xml.EscapeText(&buf, []byte(s))
return buf.String()
}
return s
}
func findResourceType(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
if fi.IsDir() {
return `<D:collection xmlns:D="DAV:"/>`, nil
}
return "", nil
}
func findDisplayName(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
if slashClean(name) == "/" {
// Hide the real name of a possibly prefixed root directory.
return "", nil
}
return escapeXML(fi.Name()), nil
}
func findContentLength(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
return strconv.FormatInt(fi.Size(), 10), nil
}
func findLastModified(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
return fi.ModTime().Format(http.TimeFormat), nil
}
func findContentType(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return "", err
}
defer f.Close()
// This implementation is based on serveContent's code in the standard net/http package.
ctype := mime.TypeByExtension(filepath.Ext(name))
if ctype != "" {
return ctype, nil
}
// Read a chunk to decide between utf-8 text and binary.
var buf [512]byte
n, err := io.ReadFull(f, buf[:])
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return "", err
}
ctype = http.DetectContentType(buf[:n])
// Rewind file.
_, err = f.Seek(0, os.SEEK_SET)
return ctype, err
}
func findETag(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
// The Apache http 2.4 web server by default concatenates the
// modification time and size of a file. We replicate the heuristic
// with nanosecond granularity.
return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()), nil
}
func findSupportedLock(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
return `` +
`<D:lockentry xmlns:D="DAV:">` +
`<D:lockscope><D:exclusive/></D:lockscope>` +
`<D:locktype><D:write/></D:locktype>` +
`</D:lockentry>`, nil
}

613
prop_test.go Normal file
View File

@ -0,0 +1,613 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"encoding/xml"
"fmt"
"net/http"
"os"
"reflect"
"sort"
"testing"
"golang.org/x/net/context"
)
func TestMemPS(t *testing.T) {
ctx := context.Background()
// calcProps calculates the getlastmodified and getetag DAV: property
// values in pstats for resource name in file-system fs.
calcProps := func(name string, fs FileSystem, ls LockSystem, pstats []Propstat) error {
fi, err := fs.Stat(ctx, name)
if err != nil {
return err
}
for _, pst := range pstats {
for i, p := range pst.Props {
switch p.XMLName {
case xml.Name{Space: "DAV:", Local: "getlastmodified"}:
p.InnerXML = []byte(fi.ModTime().Format(http.TimeFormat))
pst.Props[i] = p
case xml.Name{Space: "DAV:", Local: "getetag"}:
if fi.IsDir() {
continue
}
etag, err := findETag(ctx, fs, ls, name, fi)
if err != nil {
return err
}
p.InnerXML = []byte(etag)
pst.Props[i] = p
}
}
}
return nil
}
const (
lockEntry = `` +
`<D:lockentry xmlns:D="DAV:">` +
`<D:lockscope><D:exclusive/></D:lockscope>` +
`<D:locktype><D:write/></D:locktype>` +
`</D:lockentry>`
statForbiddenError = `<D:cannot-modify-protected-property xmlns:D="DAV:"/>`
)
type propOp struct {
op string
name string
pnames []xml.Name
patches []Proppatch
wantPnames []xml.Name
wantPropstats []Propstat
}
testCases := []struct {
desc string
noDeadProps bool
buildfs []string
propOp []propOp
}{{
desc: "propname",
buildfs: []string{"mkdir /dir", "touch /file"},
propOp: []propOp{{
op: "propname",
name: "/dir",
wantPnames: []xml.Name{
{Space: "DAV:", Local: "resourcetype"},
{Space: "DAV:", Local: "displayname"},
{Space: "DAV:", Local: "supportedlock"},
{Space: "DAV:", Local: "getlastmodified"},
},
}, {
op: "propname",
name: "/file",
wantPnames: []xml.Name{
{Space: "DAV:", Local: "resourcetype"},
{Space: "DAV:", Local: "displayname"},
{Space: "DAV:", Local: "getcontentlength"},
{Space: "DAV:", Local: "getlastmodified"},
{Space: "DAV:", Local: "getcontenttype"},
{Space: "DAV:", Local: "getetag"},
{Space: "DAV:", Local: "supportedlock"},
},
}},
}, {
desc: "allprop dir and file",
buildfs: []string{"mkdir /dir", "write /file foobarbaz"},
propOp: []propOp{{
op: "allprop",
name: "/dir",
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "resourcetype"},
InnerXML: []byte(`<D:collection xmlns:D="DAV:"/>`),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
InnerXML: []byte("dir"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getlastmodified"},
InnerXML: nil, // Calculated during test.
}, {
XMLName: xml.Name{Space: "DAV:", Local: "supportedlock"},
InnerXML: []byte(lockEntry),
}},
}},
}, {
op: "allprop",
name: "/file",
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "resourcetype"},
InnerXML: []byte(""),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
InnerXML: []byte("file"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getcontentlength"},
InnerXML: []byte("9"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getlastmodified"},
InnerXML: nil, // Calculated during test.
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getcontenttype"},
InnerXML: []byte("text/plain; charset=utf-8"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
InnerXML: nil, // Calculated during test.
}, {
XMLName: xml.Name{Space: "DAV:", Local: "supportedlock"},
InnerXML: []byte(lockEntry),
}},
}},
}, {
op: "allprop",
name: "/file",
pnames: []xml.Name{
{"DAV:", "resourcetype"},
{"foo", "bar"},
},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "resourcetype"},
InnerXML: []byte(""),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
InnerXML: []byte("file"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getcontentlength"},
InnerXML: []byte("9"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getlastmodified"},
InnerXML: nil, // Calculated during test.
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getcontenttype"},
InnerXML: []byte("text/plain; charset=utf-8"),
}, {
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
InnerXML: nil, // Calculated during test.
}, {
XMLName: xml.Name{Space: "DAV:", Local: "supportedlock"},
InnerXML: []byte(lockEntry),
}}}, {
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}}},
},
}},
}, {
desc: "propfind DAV:resourcetype",
buildfs: []string{"mkdir /dir", "touch /file"},
propOp: []propOp{{
op: "propfind",
name: "/dir",
pnames: []xml.Name{{"DAV:", "resourcetype"}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "resourcetype"},
InnerXML: []byte(`<D:collection xmlns:D="DAV:"/>`),
}},
}},
}, {
op: "propfind",
name: "/file",
pnames: []xml.Name{{"DAV:", "resourcetype"}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "resourcetype"},
InnerXML: []byte(""),
}},
}},
}},
}, {
desc: "propfind unsupported DAV properties",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "propfind",
name: "/dir",
pnames: []xml.Name{{"DAV:", "getcontentlanguage"}},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "getcontentlanguage"},
}},
}},
}, {
op: "propfind",
name: "/dir",
pnames: []xml.Name{{"DAV:", "creationdate"}},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "creationdate"},
}},
}},
}},
}, {
desc: "propfind getetag for files but not for directories",
buildfs: []string{"mkdir /dir", "touch /file"},
propOp: []propOp{{
op: "propfind",
name: "/dir",
pnames: []xml.Name{{"DAV:", "getetag"}},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
}},
}},
}, {
op: "propfind",
name: "/file",
pnames: []xml.Name{{"DAV:", "getetag"}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
InnerXML: nil, // Calculated during test.
}},
}},
}},
}, {
desc: "proppatch property on no-dead-properties file system",
buildfs: []string{"mkdir /dir"},
noDeadProps: true,
propOp: []propOp{{
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusForbidden,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}, {
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusForbidden,
XMLError: statForbiddenError,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
}},
}},
}},
}, {
desc: "proppatch dead property",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}, {
op: "propfind",
name: "/dir",
pnames: []xml.Name{{Space: "foo", Local: "bar"}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}},
}},
}},
}, {
desc: "proppatch dead property with failed dependency",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}},
}, {
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
InnerXML: []byte("xxx"),
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusForbidden,
XMLError: statForbiddenError,
Props: []Property{{
XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
}},
}, {
Status: StatusFailedDependency,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}, {
op: "propfind",
name: "/dir",
pnames: []xml.Name{{Space: "foo", Local: "bar"}},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}},
}, {
desc: "proppatch remove dead property",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}, {
XMLName: xml.Name{Space: "spam", Local: "ham"},
InnerXML: []byte("eggs"),
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}, {
XMLName: xml.Name{Space: "spam", Local: "ham"},
}},
}},
}, {
op: "propfind",
name: "/dir",
pnames: []xml.Name{
{Space: "foo", Local: "bar"},
{Space: "spam", Local: "ham"},
},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}, {
XMLName: xml.Name{Space: "spam", Local: "ham"},
InnerXML: []byte("eggs"),
}},
}},
}, {
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Remove: true,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}, {
op: "propfind",
name: "/dir",
pnames: []xml.Name{
{Space: "foo", Local: "bar"},
{Space: "spam", Local: "ham"},
},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}, {
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "spam", Local: "ham"},
InnerXML: []byte("eggs"),
}},
}},
}},
}, {
desc: "propname with dead property",
buildfs: []string{"touch /file"},
propOp: []propOp{{
op: "proppatch",
name: "/file",
patches: []Proppatch{{
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
InnerXML: []byte("baz"),
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}, {
op: "propname",
name: "/file",
wantPnames: []xml.Name{
{Space: "DAV:", Local: "resourcetype"},
{Space: "DAV:", Local: "displayname"},
{Space: "DAV:", Local: "getcontentlength"},
{Space: "DAV:", Local: "getlastmodified"},
{Space: "DAV:", Local: "getcontenttype"},
{Space: "DAV:", Local: "getetag"},
{Space: "DAV:", Local: "supportedlock"},
{Space: "foo", Local: "bar"},
},
}},
}, {
desc: "proppatch remove unknown dead property",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "proppatch",
name: "/dir",
patches: []Proppatch{{
Remove: true,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
wantPropstats: []Propstat{{
Status: http.StatusOK,
Props: []Property{{
XMLName: xml.Name{Space: "foo", Local: "bar"},
}},
}},
}},
}, {
desc: "bad: propfind unknown property",
buildfs: []string{"mkdir /dir"},
propOp: []propOp{{
op: "propfind",
name: "/dir",
pnames: []xml.Name{{"foo:", "bar"}},
wantPropstats: []Propstat{{
Status: http.StatusNotFound,
Props: []Property{{
XMLName: xml.Name{Space: "foo:", Local: "bar"},
}},
}},
}},
}}
for _, tc := range testCases {
fs, err := buildTestFS(tc.buildfs)
if err != nil {
t.Fatalf("%s: cannot create test filesystem: %v", tc.desc, err)
}
if tc.noDeadProps {
fs = noDeadPropsFS{fs}
}
ls := NewMemLS()
for _, op := range tc.propOp {
desc := fmt.Sprintf("%s: %s %s", tc.desc, op.op, op.name)
if err = calcProps(op.name, fs, ls, op.wantPropstats); err != nil {
t.Fatalf("%s: calcProps: %v", desc, err)
}
// Call property system.
var propstats []Propstat
switch op.op {
case "propname":
pnames, err := propnames(ctx, fs, ls, op.name)
if err != nil {
t.Errorf("%s: got error %v, want nil", desc, err)
continue
}
sort.Sort(byXMLName(pnames))
sort.Sort(byXMLName(op.wantPnames))
if !reflect.DeepEqual(pnames, op.wantPnames) {
t.Errorf("%s: pnames\ngot %q\nwant %q", desc, pnames, op.wantPnames)
}
continue
case "allprop":
propstats, err = Allprop(ctx, fs, ls, op.name, op.pnames)
case "propfind":
propstats, err = Props(ctx, fs, ls, op.name, op.pnames)
case "proppatch":
propstats, err = patch(ctx, fs, ls, op.name, op.patches)
default:
t.Fatalf("%s: %s not implemented", desc, op.op)
}
if err != nil {
t.Errorf("%s: got error %v, want nil", desc, err)
continue
}
// Compare return values from allprop, propfind or proppatch.
for _, pst := range propstats {
sort.Sort(byPropname(pst.Props))
}
for _, pst := range op.wantPropstats {
sort.Sort(byPropname(pst.Props))
}
sort.Sort(byStatus(propstats))
sort.Sort(byStatus(op.wantPropstats))
if !reflect.DeepEqual(propstats, op.wantPropstats) {
t.Errorf("%s: propstat\ngot %q\nwant %q", desc, propstats, op.wantPropstats)
}
}
}
}
func cmpXMLName(a, b xml.Name) bool {
if a.Space != b.Space {
return a.Space < b.Space
}
return a.Local < b.Local
}
type byXMLName []xml.Name
func (b byXMLName) Len() int { return len(b) }
func (b byXMLName) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
func (b byXMLName) Less(i, j int) bool { return cmpXMLName(b[i], b[j]) }
type byPropname []Property
func (b byPropname) Len() int { return len(b) }
func (b byPropname) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
func (b byPropname) Less(i, j int) bool { return cmpXMLName(b[i].XMLName, b[j].XMLName) }
type byStatus []Propstat
func (b byStatus) Len() int { return len(b) }
func (b byStatus) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
func (b byStatus) Less(i, j int) bool { return b[i].Status < b[j].Status }
type noDeadPropsFS struct {
FileSystem
}
func (fs noDeadPropsFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
f, err := fs.FileSystem.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
return noDeadPropsFile{f}, nil
}
// noDeadPropsFile wraps a File but strips any optional DeadPropsHolder methods
// provided by the underlying File implementation.
type noDeadPropsFile struct {
f File
}
func (f noDeadPropsFile) Close() error { return f.f.Close() }
func (f noDeadPropsFile) Read(p []byte) (int, error) { return f.f.Read(p) }
func (f noDeadPropsFile) Readdir(count int) ([]os.FileInfo, error) { return f.f.Readdir(count) }
func (f noDeadPropsFile) Seek(off int64, whence int) (int64, error) { return f.f.Seek(off, whence) }
func (f noDeadPropsFile) Stat() (os.FileInfo, error) { return f.f.Stat() }
func (f noDeadPropsFile) Write(p []byte) (int, error) { return f.f.Write(p) }

933
server.go

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +1,2 @@
// Package webdav provides a client and server WebDAV filesystem implementation.
//
// WebDAV is defined in RFC 4918.
// Package webdav provides a WebDAV implementation, as defined in RFC 4918.
package webdav
import (
"time"
"github.com/emersion/go-webdav/internal"
)
// FileInfo holds information about a WebDAV file.
type FileInfo struct {
Path string
Size int64
ModTime time.Time
IsDir bool
MIMEType string
ETag string
}
type CreateOptions struct {
IfMatch ConditionalMatch
IfNoneMatch ConditionalMatch
}
type CopyOptions struct {
NoRecursive bool
NoOverwrite bool
}
type MoveOptions struct {
NoOverwrite bool
}
// ConditionalMatch represents the value of a conditional header
// according to RFC 2068 section 14.25 and RFC 2068 section 14.26
// The (optional) value can either be a wildcard or an ETag.
type ConditionalMatch string
func (val ConditionalMatch) IsSet() bool {
return val != ""
}
func (val ConditionalMatch) IsWildcard() bool {
return val == "*"
}
func (val ConditionalMatch) ETag() (string, error) {
var e internal.ETag
if err := e.UnmarshalText([]byte(val)); err != nil {
return "", err
}
return string(e), nil
}
func (val ConditionalMatch) MatchETag(etag string) (bool, error) {
if val.IsWildcard() {
return true, nil
}
t, err := val.ETag()
return t == etag, err
}

344
webdav_test.go Normal file
View File

@ -0,0 +1,344 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"golang.org/x/net/context"
)
// TODO: add tests to check XML responses with the expected prefix path
func TestPrefix(t *testing.T) {
const dst, blah = "Destination", "blah blah blah"
// createLockBody comes from the example in Section 9.10.7.
const createLockBody = `<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D='DAV:'>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner>
<D:href>http://example.org/~ejw/contact.html</D:href>
</D:owner>
</D:lockinfo>
`
do := func(method, urlStr string, body string, wantStatusCode int, headers ...string) (http.Header, error) {
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return nil, err
}
for len(headers) >= 2 {
req.Header.Add(headers[0], headers[1])
headers = headers[2:]
}
res, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != wantStatusCode {
return nil, fmt.Errorf("got status code %d, want %d", res.StatusCode, wantStatusCode)
}
return res.Header, nil
}
prefixes := []string{
"/",
"/a/",
"/a/b/",
"/a/b/c/",
}
ctx := context.Background()
for _, prefix := range prefixes {
fs := NewMemFS()
h := &Handler{
FileSystem: fs,
LockSystem: NewMemLS(),
}
mux := http.NewServeMux()
if prefix != "/" {
h.Prefix = prefix
}
mux.Handle(prefix, h)
srv := httptest.NewServer(mux)
defer srv.Close()
// The script is:
// MKCOL /a
// MKCOL /a/b
// PUT /a/b/c
// COPY /a/b/c /a/b/d
// MKCOL /a/b/e
// MOVE /a/b/d /a/b/e/f
// LOCK /a/b/e/g
// PUT /a/b/e/g
// which should yield the (possibly stripped) filenames /a/b/c,
// /a/b/e/f and /a/b/e/g, plus their parent directories.
wantA := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusMovedPermanently,
"/a/b/": http.StatusNotFound,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if _, err := do("MKCOL", srv.URL+"/a", "", wantA); err != nil {
t.Errorf("prefix=%-9q MKCOL /a: %v", prefix, err)
continue
}
wantB := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusMovedPermanently,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if _, err := do("MKCOL", srv.URL+"/a/b", "", wantB); err != nil {
t.Errorf("prefix=%-9q MKCOL /a/b: %v", prefix, err)
continue
}
wantC := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusMovedPermanently,
}[prefix]
if _, err := do("PUT", srv.URL+"/a/b/c", blah, wantC); err != nil {
t.Errorf("prefix=%-9q PUT /a/b/c: %v", prefix, err)
continue
}
wantD := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusMovedPermanently,
}[prefix]
if _, err := do("COPY", srv.URL+"/a/b/c", "", wantD, dst, srv.URL+"/a/b/d"); err != nil {
t.Errorf("prefix=%-9q COPY /a/b/c /a/b/d: %v", prefix, err)
continue
}
wantE := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if _, err := do("MKCOL", srv.URL+"/a/b/e", "", wantE); err != nil {
t.Errorf("prefix=%-9q MKCOL /a/b/e: %v", prefix, err)
continue
}
wantF := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if _, err := do("MOVE", srv.URL+"/a/b/d", "", wantF, dst, srv.URL+"/a/b/e/f"); err != nil {
t.Errorf("prefix=%-9q MOVE /a/b/d /a/b/e/f: %v", prefix, err)
continue
}
var lockToken string
wantG := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if h, err := do("LOCK", srv.URL+"/a/b/e/g", createLockBody, wantG); err != nil {
t.Errorf("prefix=%-9q LOCK /a/b/e/g: %v", prefix, err)
continue
} else {
lockToken = h.Get("Lock-Token")
}
ifHeader := fmt.Sprintf("<%s/a/b/e/g> (%s)", srv.URL, lockToken)
wantH := map[string]int{
"/": http.StatusCreated,
"/a/": http.StatusCreated,
"/a/b/": http.StatusCreated,
"/a/b/c/": http.StatusNotFound,
}[prefix]
if _, err := do("PUT", srv.URL+"/a/b/e/g", blah, wantH, "If", ifHeader); err != nil {
t.Errorf("prefix=%-9q PUT /a/b/e/g: %v", prefix, err)
continue
}
got, err := find(ctx, nil, fs, "/")
if err != nil {
t.Errorf("prefix=%-9q find: %v", prefix, err)
continue
}
sort.Strings(got)
want := map[string][]string{
"/": {"/", "/a", "/a/b", "/a/b/c", "/a/b/e", "/a/b/e/f", "/a/b/e/g"},
"/a/": {"/", "/b", "/b/c", "/b/e", "/b/e/f", "/b/e/g"},
"/a/b/": {"/", "/c", "/e", "/e/f", "/e/g"},
"/a/b/c/": {"/"},
}[prefix]
if !reflect.DeepEqual(got, want) {
t.Errorf("prefix=%-9q find:\ngot %v\nwant %v", prefix, got, want)
continue
}
}
}
func TestEscapeXML(t *testing.T) {
// These test cases aren't exhaustive, and there is more than one way to
// escape e.g. a quot (as "&#34;" or "&quot;") or an apos. We presume that
// the encoding/xml package tests xml.EscapeText more thoroughly. This test
// here is just a sanity check for this package's escapeXML function, and
// its attempt to provide a fast path (and avoid a bytes.Buffer allocation)
// when escaping filenames is obviously a no-op.
testCases := map[string]string{
"": "",
" ": " ",
"&": "&amp;",
"*": "*",
"+": "+",
",": ",",
"-": "-",
".": ".",
"/": "/",
"0": "0",
"9": "9",
":": ":",
"<": "&lt;",
">": "&gt;",
"A": "A",
"_": "_",
"a": "a",
"~": "~",
"\u0201": "\u0201",
"&amp;": "&amp;amp;",
"foo&<b/ar>baz": "foo&amp;&lt;b/ar&gt;baz",
}
for in, want := range testCases {
if got := escapeXML(in); got != want {
t.Errorf("in=%q: got %q, want %q", in, got, want)
}
}
}
func TestFilenameEscape(t *testing.T) {
hrefRe := regexp.MustCompile(`<href xmlns="DAV:">([^<]*)</href>`)
displayNameRe := regexp.MustCompile(`<displayname xmlns="DAV:">([^<]*)</displayname>`)
do := func(method, urlStr string) (string, string, error) {
req, err := http.NewRequest(method, urlStr, nil)
if err != nil {
return "", "", err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", "", err
}
hrefMatch := hrefRe.FindStringSubmatch(string(b))
if len(hrefMatch) != 2 {
return "", "", errors.New("D:href not found")
}
displayNameMatch := displayNameRe.FindStringSubmatch(string(b))
if len(displayNameMatch) != 2 {
return "", "", errors.New("D:displayname not found")
}
return hrefMatch[1], displayNameMatch[1], nil
}
testCases := []struct {
name, wantHref, wantDisplayName string
}{{
name: `/foo%bar`,
wantHref: `/foo%25bar`,
wantDisplayName: `foo%bar`,
}, {
name: `/こんにちわ世界`,
wantHref: `/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%82%8F%E4%B8%96%E7%95%8C`,
wantDisplayName: `こんにちわ世界`,
}, {
name: `/Program Files/`,
wantHref: `/Program%20Files`,
wantDisplayName: `Program Files`,
}, {
name: `/go+lang`,
wantHref: `/go+lang`,
wantDisplayName: `go+lang`,
}, {
name: `/go&lang`,
wantHref: `/go&amp;lang`,
wantDisplayName: `go&amp;lang`,
}, {
name: `/go<lang`,
wantHref: `/go%3Clang`,
wantDisplayName: `go&lt;lang`,
}}
ctx := context.Background()
fs := NewMemFS()
for _, tc := range testCases {
if strings.HasSuffix(tc.name, "/") {
if err := fs.Mkdir(ctx, tc.name, 0755); err != nil {
t.Fatalf("name=%q: Mkdir: %v", tc.name, err)
}
} else {
f, err := fs.OpenFile(ctx, tc.name, os.O_CREATE, 0644)
if err != nil {
t.Fatalf("name=%q: OpenFile: %v", tc.name, err)
}
f.Close()
}
}
srv := httptest.NewServer(&Handler{
FileSystem: fs,
LockSystem: NewMemLS(),
})
defer srv.Close()
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
for _, tc := range testCases {
u.Path = tc.name
gotHref, gotDisplayName, err := do("PROPFIND", u.String())
if err != nil {
t.Errorf("name=%q: PROPFIND: %v", tc.name, err)
continue
}
if gotHref != tc.wantHref {
t.Errorf("name=%q: got href %q, want %q", tc.name, gotHref, tc.wantHref)
}
if gotDisplayName != tc.wantDisplayName {
t.Errorf("name=%q: got dispayname %q, want %q", tc.name, gotDisplayName, tc.wantDisplayName)
}
}
}

486
xml.go Normal file
View File

@ -0,0 +1,486 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
// The XML encoding is covered by Section 14.
// http://www.webdav.org/specs/rfc4918.html#xml.element.definitions
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
"unicode"
)
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_lockinfo
type lockInfo struct {
XMLName xml.Name `xml:"lockinfo"`
Exclusive *struct{} `xml:"lockscope>exclusive"`
Shared *struct{} `xml:"lockscope>shared"`
Write *struct{} `xml:"locktype>write"`
Owner owner `xml:"owner"`
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_owner
type owner struct {
InnerXML string `xml:",innerxml"`
}
func readLockInfo(r io.Reader) (li lockInfo, status int, err error) {
c := &countingReader{r: r}
if err = xml.NewDecoder(c).Decode(&li); err != nil {
if err == io.EOF {
if c.n == 0 {
// An empty body means to refresh the lock.
// http://www.webdav.org/specs/rfc4918.html#refreshing-locks
return lockInfo{}, 0, nil
}
err = errInvalidLockInfo
}
return lockInfo{}, http.StatusBadRequest, err
}
// We only support exclusive (non-shared) write locks. In practice, these are
// the only types of locks that seem to matter.
if li.Exclusive == nil || li.Shared != nil || li.Write == nil {
return lockInfo{}, http.StatusNotImplemented, errUnsupportedLockInfo
}
return li, 0, nil
}
type countingReader struct {
n int
r io.Reader
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}
func writeLockInfo(w io.Writer, token string, ld LockDetails) (int, error) {
depth := "infinity"
if ld.ZeroDepth {
depth = "0"
}
timeout := ld.Duration / time.Second
return fmt.Fprintf(w, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"+
"<D:prop xmlns:D=\"DAV:\"><D:lockdiscovery><D:activelock>\n"+
" <D:locktype><D:write/></D:locktype>\n"+
" <D:lockscope><D:exclusive/></D:lockscope>\n"+
" <D:depth>%s</D:depth>\n"+
" <D:owner>%s</D:owner>\n"+
" <D:timeout>Second-%d</D:timeout>\n"+
" <D:locktoken><D:href>%s</D:href></D:locktoken>\n"+
" <D:lockroot><D:href>%s</D:href></D:lockroot>\n"+
"</D:activelock></D:lockdiscovery></D:prop>",
depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root),
)
}
func escape(s string) string {
for i := 0; i < len(s); i++ {
switch s[i] {
case '"', '&', '\'', '<', '>':
b := bytes.NewBuffer(nil)
xml.EscapeText(b, []byte(s))
return b.String()
}
}
return s
}
// Next returns the next token, if any, in the XML stream of d.
// RFC 4918 requires to ignore comments, processing instructions
// and directives.
// http://www.webdav.org/specs/rfc4918.html#property_values
// http://www.webdav.org/specs/rfc4918.html#xml-extensibility
func next(d *xml.Decoder) (xml.Token, error) {
for {
t, err := d.Token()
if err != nil {
return t, err
}
switch t := t.(type) {
case xml.Comment, xml.Directive, xml.ProcInst:
continue
case xml.CharData:
for _, c := range t {
if !unicode.IsSpace(rune(c)) {
return t, fmt.Errorf("unexpected non-empty xml.CharData")
}
}
default:
return t, nil
}
}
}
// TODO: the xml.Name isn't enough, enclosed elements matter too (e.g.
// CardDAV's address-data)
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
type PropfindProps []xml.Name
// UnmarshalXML appends the property names enclosed within start to pn.
//
// It returns an error if start does not contain any properties or if
// properties contain values. Character data between properties is ignored.
func (pn *PropfindProps) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
for {
t, err := next(d)
if err != nil {
return err
}
switch t.(type) {
case xml.EndElement:
if len(*pn) == 0 {
return fmt.Errorf("%s must not be empty", start.Name.Local)
}
return nil
case xml.StartElement:
name := t.(xml.StartElement).Name
if err := d.Skip(); err != nil {
return err
}
*pn = append(*pn, xml.Name(name))
}
}
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
type propfind struct {
XMLName xml.Name `xml:"DAV: propfind"`
Allprop *struct{} `xml:"DAV: allprop"`
Propname *struct{} `xml:"DAV: propname"`
Prop PropfindProps `xml:"DAV: prop"`
Include PropfindProps `xml:"DAV: include"`
}
func readPropfind(r io.Reader) (pf propfind, status int, err error) {
c := countingReader{r: r}
if err = xml.NewDecoder(&c).Decode(&pf); err != nil {
if err == io.EOF {
if c.n == 0 {
// An empty body means to propfind allprop.
// http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
return propfind{Allprop: new(struct{})}, 0, nil
}
err = errInvalidPropfind
}
return propfind{}, http.StatusBadRequest, err
}
if pf.Allprop == nil && pf.Include != nil {
return propfind{}, http.StatusBadRequest, errInvalidPropfind
}
if pf.Allprop != nil && (pf.Prop != nil || pf.Propname != nil) {
return propfind{}, http.StatusBadRequest, errInvalidPropfind
}
if pf.Prop != nil && pf.Propname != nil {
return propfind{}, http.StatusBadRequest, errInvalidPropfind
}
if pf.Propname == nil && pf.Allprop == nil && pf.Prop == nil {
return propfind{}, http.StatusBadRequest, errInvalidPropfind
}
return pf, 0, nil
}
// Property represents a single DAV resource property as defined in RFC 4918.
// See http://www.webdav.org/specs/rfc4918.html#data.model.for.resource.properties
type Property struct {
// XMLName is the fully qualified name that identifies this property.
XMLName xml.Name
// Lang is an optional xml:lang attribute.
Lang string `xml:"xml:lang,attr,omitempty"`
// InnerXML contains the XML representation of the property value.
// See http://www.webdav.org/specs/rfc4918.html#property_values
//
// Property values of complex type or mixed-content must have fully
// expanded XML namespaces or be self-contained with according
// XML namespace declarations. They must not rely on any XML
// namespace declarations within the scope of the XML document,
// even including the DAV: namespace.
InnerXML []byte `xml:",innerxml"`
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
type xmlError struct {
XMLName xml.Name `xml:"DAV: error"`
InnerXML []byte `xml:",innerxml"`
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat
type propstat struct {
Prop []Property `xml:"DAV: prop>_ignored_"`
Status string `xml:"DAV: status"`
Error *xmlError `xml:"DAV: error"`
ResponseDescription string `xml:"DAV: responsedescription,omitempty"`
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_response
type Response struct {
Href []string
Propstat []Propstat
Status int
XMLError string
ResponseDescription string
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_response
type response struct {
XMLName xml.Name `xml:"DAV: response"`
Href []string `xml:"DAV: href"`
Propstat []propstat `xml:"DAV: propstat"`
Status string `xml:"DAV: status,omitempty"`
Error *xmlError `xml:"DAV: error"`
ResponseDescription string `xml:"DAV: responsedescription,omitempty"`
}
// MultistatusWriter marshals one or more Responses into a XML
// multistatus response.
// See http://www.webdav.org/specs/rfc4918.html#ELEMENT_multistatus
type MultistatusWriter struct {
// ResponseDescription contains the optional responsedescription
// of the multistatus XML element. Only the latest content before
// close will be emitted. Empty response descriptions are not
// written.
ResponseDescription string
w http.ResponseWriter
enc *xml.Encoder
}
func NewMultistatusWriter(w http.ResponseWriter) *MultistatusWriter {
return &MultistatusWriter{w: w}
}
// Write validates and emits a DAV response as part of a multistatus response
// element.
//
// It sets the HTTP status code of its underlying http.ResponseWriter to 207
// (Multi-Status) and populates the Content-Type header. If r is the
// first, valid response to be written, Write prepends the XML representation
// of r with a multistatus tag. Callers must call close after the last response
// has been written.
func (w *MultistatusWriter) Write(r *Response) error {
rr := &response{
Href: r.Href,
Propstat: make([]propstat, 0, len(r.Propstat)),
}
for _, p := range r.Propstat {
var xmlErr *xmlError
if p.XMLError != "" {
xmlErr = &xmlError{InnerXML: []byte(p.XMLError)}
}
rr.Propstat = append(rr.Propstat, propstat{
Status: fmt.Sprintf("HTTP/1.1 %d %s", p.Status, StatusText(p.Status)),
Prop: p.Props,
ResponseDescription: p.ResponseDescription,
Error: xmlErr,
})
}
return w.write(rr)
}
func (w *MultistatusWriter) write(r *response) error {
switch len(r.Href) {
case 0:
return errInvalidResponse
case 1:
if len(r.Propstat) > 0 != (r.Status == "") {
return errInvalidResponse
}
default:
if len(r.Propstat) > 0 || r.Status == "" {
return errInvalidResponse
}
}
err := w.writeHeader()
if err != nil {
return err
}
return w.enc.Encode(r)
}
// writeHeader writes a XML multistatus start element on w's underlying
// http.ResponseWriter and returns the result of the write operation.
// After the first write attempt, writeHeader becomes a no-op.
func (w *MultistatusWriter) writeHeader() error {
if w.enc != nil {
return nil
}
w.w.Header().Add("Content-Type", "text/xml; charset=utf-8")
w.w.WriteHeader(StatusMulti)
_, err := fmt.Fprintf(w.w, xml.Header)
if err != nil {
return err
}
w.enc = xml.NewEncoder(w.w)
return w.enc.EncodeToken(xml.StartElement{
Name: xml.Name{
Space: "DAV:",
Local: "multistatus",
},
Attr: []xml.Attr{{
Name: xml.Name{Space: "xmlns", Local: "D"},
Value: "DAV:",
}},
})
}
// Close completes the marshalling of the multistatus response. It returns
// an error if the multistatus response could not be completed. If both the
// return value and field enc of w are nil, then no multistatus response has
// been written.
func (w *MultistatusWriter) Close() error {
if w.enc == nil {
return nil
}
var end []xml.Token
if w.ResponseDescription != "" {
name := xml.Name{Space: "DAV:", Local: "responsedescription"}
end = append(end,
xml.StartElement{Name: name},
xml.CharData(w.ResponseDescription),
xml.EndElement{Name: name},
)
}
end = append(end, xml.EndElement{
Name: xml.Name{Space: "DAV:", Local: "multistatus"},
})
for _, t := range end {
err := w.enc.EncodeToken(t)
if err != nil {
return err
}
}
return w.enc.Flush()
}
var xmlLangName = xml.Name{Space: "http://www.w3.org/XML/1998/namespace", Local: "lang"}
func xmlLang(s xml.StartElement, d string) string {
for _, attr := range s.Attr {
if attr.Name == xmlLangName {
return attr.Value
}
}
return d
}
type xmlValue []byte
func (v *xmlValue) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
// The XML value of a property can be arbitrary, mixed-content XML.
// To make sure that the unmarshalled value contains all required
// namespaces, we encode all the property value XML tokens into a
// buffer. This forces the encoder to redeclare any used namespaces.
var b bytes.Buffer
e := xml.NewEncoder(&b)
for {
t, err := next(d)
if err != nil {
return err
}
if e, ok := t.(xml.EndElement); ok && e.Name == start.Name {
break
}
if err = e.EncodeToken(t); err != nil {
return err
}
}
err := e.Flush()
if err != nil {
return err
}
*v = b.Bytes()
return nil
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for proppatch)
type proppatchProps []Property
// UnmarshalXML appends the property names and values enclosed within start
// to ps.
//
// An xml:lang attribute that is defined either on the DAV:prop or property
// name XML element is propagated to the property's Lang field.
//
// UnmarshalXML returns an error if start does not contain any properties or if
// property values contain syntactically incorrect XML.
func (ps *proppatchProps) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
lang := xmlLang(start, "")
for {
t, err := next(d)
if err != nil {
return err
}
switch elem := t.(type) {
case xml.EndElement:
if len(*ps) == 0 {
return fmt.Errorf("%s must not be empty", start.Name.Local)
}
return nil
case xml.StartElement:
p := Property{
XMLName: xml.Name(t.(xml.StartElement).Name),
Lang: xmlLang(t.(xml.StartElement), lang),
}
err = d.DecodeElement(((*xmlValue)(&p.InnerXML)), &elem)
if err != nil {
return err
}
*ps = append(*ps, p)
}
}
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_set
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_remove
type setRemove struct {
XMLName xml.Name
Lang string `xml:"xml:lang,attr,omitempty"`
Prop proppatchProps `xml:"DAV: prop"`
}
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propertyupdate
type propertyupdate struct {
XMLName xml.Name `xml:"DAV: propertyupdate"`
Lang string `xml:"xml:lang,attr,omitempty"`
SetRemove []setRemove `xml:",any"`
}
func readProppatch(r io.Reader) (patches []Proppatch, status int, err error) {
var pu propertyupdate
if err = xml.NewDecoder(r).Decode(&pu); err != nil {
return nil, http.StatusBadRequest, err
}
for _, op := range pu.SetRemove {
remove := false
switch op.XMLName {
case xml.Name{Space: "DAV:", Local: "set"}:
// No-op.
case xml.Name{Space: "DAV:", Local: "remove"}:
for _, p := range op.Prop {
if len(p.InnerXML) > 0 {
return nil, http.StatusBadRequest, errInvalidProppatch
}
}
remove = true
default:
return nil, http.StatusBadRequest, errInvalidProppatch
}
patches = append(patches, Proppatch{Remove: remove, Props: op.Prop})
}
return patches, 0, nil
}

904
xml_test.go Normal file
View File

@ -0,0 +1,904 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package webdav
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
)
func TestReadLockInfo(t *testing.T) {
// The "section x.y.z" test cases come from section x.y.z of the spec at
// http://www.webdav.org/specs/rfc4918.html
testCases := []struct {
desc string
input string
wantLI lockInfo
wantStatus int
}{{
"bad: junk",
"xxx",
lockInfo{},
http.StatusBadRequest,
}, {
"bad: invalid owner XML",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n" +
" <D:owner>\n" +
" <D:href> no end tag \n" +
" </D:owner>\n" +
"</D:lockinfo>",
lockInfo{},
http.StatusBadRequest,
}, {
"bad: invalid UTF-8",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n" +
" <D:owner>\n" +
" <D:href> \xff </D:href>\n" +
" </D:owner>\n" +
"</D:lockinfo>",
lockInfo{},
http.StatusBadRequest,
}, {
"bad: unfinished XML #1",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n",
lockInfo{},
http.StatusBadRequest,
}, {
"bad: unfinished XML #2",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n" +
" <D:owner>\n",
lockInfo{},
http.StatusBadRequest,
}, {
"good: empty",
"",
lockInfo{},
0,
}, {
"good: plain-text owner",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n" +
" <D:owner>gopher</D:owner>\n" +
"</D:lockinfo>",
lockInfo{
XMLName: xml.Name{Space: "DAV:", Local: "lockinfo"},
Exclusive: new(struct{}),
Write: new(struct{}),
Owner: owner{
InnerXML: "gopher",
},
},
0,
}, {
"section 9.10.7",
"" +
"<D:lockinfo xmlns:D='DAV:'>\n" +
" <D:lockscope><D:exclusive/></D:lockscope>\n" +
" <D:locktype><D:write/></D:locktype>\n" +
" <D:owner>\n" +
" <D:href>http://example.org/~ejw/contact.html</D:href>\n" +
" </D:owner>\n" +
"</D:lockinfo>",
lockInfo{
XMLName: xml.Name{Space: "DAV:", Local: "lockinfo"},
Exclusive: new(struct{}),
Write: new(struct{}),
Owner: owner{
InnerXML: "\n <D:href>http://example.org/~ejw/contact.html</D:href>\n ",
},
},
0,
}}
for _, tc := range testCases {
li, status, err := readLockInfo(strings.NewReader(tc.input))
if tc.wantStatus != 0 {
if err == nil {
t.Errorf("%s: got nil error, want non-nil", tc.desc)
continue
}
} else if err != nil {
t.Errorf("%s: %v", tc.desc, err)
continue
}
if !reflect.DeepEqual(li, tc.wantLI) || status != tc.wantStatus {
t.Errorf("%s:\ngot lockInfo=%v, status=%v\nwant lockInfo=%v, status=%v",
tc.desc, li, status, tc.wantLI, tc.wantStatus)
continue
}
}
}
func TestReadPropfind(t *testing.T) {
testCases := []struct {
desc string
input string
wantPF propfind
wantStatus int
}{{
desc: "propfind: propname",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:propname/>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Propname: new(struct{}),
},
}, {
desc: "propfind: empty body means allprop",
input: "",
wantPF: propfind{
Allprop: new(struct{}),
},
}, {
desc: "propfind: allprop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:allprop/>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Allprop: new(struct{}),
},
}, {
desc: "propfind: allprop followed by include",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:allprop/>\n" +
" <A:include><A:displayname/></A:include>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Allprop: new(struct{}),
Include: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: include followed by allprop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:include><A:displayname/></A:include>\n" +
" <A:allprop/>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Allprop: new(struct{}),
Include: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: propfind",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:displayname/></A:prop>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Prop: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: prop with ignored comments",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop>\n" +
" <!-- ignore -->\n" +
" <A:displayname><!-- ignore --></A:displayname>\n" +
" </A:prop>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Prop: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: propfind with ignored whitespace",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop> <A:displayname/></A:prop>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Prop: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: propfind with ignored mixed-content",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop>foo<A:displayname/>bar</A:prop>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Prop: PropfindProps{xml.Name{Space: "DAV:", Local: "displayname"}},
},
}, {
desc: "propfind: propname with ignored element (section A.4)",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:propname/>\n" +
" <E:leave-out xmlns:E='E:'>*boss*</E:leave-out>\n" +
"</A:propfind>",
wantPF: propfind{
XMLName: xml.Name{Space: "DAV:", Local: "propfind"},
Propname: new(struct{}),
},
}, {
desc: "propfind: bad: junk",
input: "xxx",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: propname and allprop (section A.3)",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:propname/>" +
" <A:allprop/>" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: propname and prop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:displayname/></A:prop>\n" +
" <A:propname/>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: allprop and prop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:allprop/>\n" +
" <A:prop><A:foo/><A:/prop>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: empty propfind with ignored element (section A.4)",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <E:expired-props/>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: empty prop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop/>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: prop with just chardata",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop>foo</A:prop>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "bad: interrupted prop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:foo></A:prop>\n",
wantStatus: http.StatusBadRequest,
}, {
desc: "bad: malformed end element prop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:foo/></A:bar></A:prop>\n",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: property with chardata value",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:foo>bar</A:foo></A:prop>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: property with whitespace value",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:prop><A:foo> </A:foo></A:prop>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}, {
desc: "propfind: bad: include without allprop",
input: "" +
"<A:propfind xmlns:A='DAV:'>\n" +
" <A:include><A:foo/></A:include>\n" +
"</A:propfind>",
wantStatus: http.StatusBadRequest,
}}
for _, tc := range testCases {
pf, status, err := readPropfind(strings.NewReader(tc.input))
if tc.wantStatus != 0 {
if err == nil {
t.Errorf("%s: got nil error, want non-nil", tc.desc)
continue
}
} else if err != nil {
t.Errorf("%s: %v", tc.desc, err)
continue
}
if !reflect.DeepEqual(pf, tc.wantPF) || status != tc.wantStatus {
t.Errorf("%s:\ngot propfind=%v, status=%v\nwant propfind=%v, status=%v",
tc.desc, pf, status, tc.wantPF, tc.wantStatus)
continue
}
}
}
func TestMultistatusWriter(t *testing.T) {
///The "section x.y.z" test cases come from section x.y.z of the spec at
// http://www.webdav.org/specs/rfc4918.html
testCases := []struct {
desc string
responses []response
respdesc string
writeHeader bool
wantXML string
wantCode int
wantErr error
}{{
desc: "section 9.2.2 (failed dependency)",
responses: []response{{
Href: []string{"http://example.com/foo"},
Propstat: []propstat{{
Prop: []Property{{
XMLName: xml.Name{
Space: "http://ns.example.com/",
Local: "Authors",
},
}},
Status: "HTTP/1.1 424 Failed Dependency",
}, {
Prop: []Property{{
XMLName: xml.Name{
Space: "http://ns.example.com/",
Local: "Copyright-Owner",
},
}},
Status: "HTTP/1.1 409 Conflict",
}},
ResponseDescription: "Copyright Owner cannot be deleted or altered.",
}},
wantXML: `` +
`<?xml version="1.0" encoding="UTF-8"?>` +
`<multistatus xmlns="DAV:">` +
` <response>` +
` <href>http://example.com/foo</href>` +
` <propstat>` +
` <prop>` +
` <Authors xmlns="http://ns.example.com/"></Authors>` +
` </prop>` +
` <status>HTTP/1.1 424 Failed Dependency</status>` +
` </propstat>` +
` <propstat xmlns="DAV:">` +
` <prop>` +
` <Copyright-Owner xmlns="http://ns.example.com/"></Copyright-Owner>` +
` </prop>` +
` <status>HTTP/1.1 409 Conflict</status>` +
` </propstat>` +
` <responsedescription>Copyright Owner cannot be deleted or altered.</responsedescription>` +
`</response>` +
`</multistatus>`,
wantCode: StatusMulti,
}, {
desc: "section 9.6.2 (lock-token-submitted)",
responses: []response{{
Href: []string{"http://example.com/foo"},
Status: "HTTP/1.1 423 Locked",
Error: &xmlError{
InnerXML: []byte(`<lock-token-submitted xmlns="DAV:"/>`),
},
}},
wantXML: `` +
`<?xml version="1.0" encoding="UTF-8"?>` +
`<multistatus xmlns="DAV:">` +
` <response>` +
` <href>http://example.com/foo</href>` +
` <status>HTTP/1.1 423 Locked</status>` +
` <error><lock-token-submitted xmlns="DAV:"/></error>` +
` </response>` +
`</multistatus>`,
wantCode: StatusMulti,
}, {
desc: "section 9.1.3",
responses: []response{{
Href: []string{"http://example.com/foo"},
Propstat: []propstat{{
Prop: []Property{{
XMLName: xml.Name{Space: "http://ns.example.com/boxschema/", Local: "bigbox"},
InnerXML: []byte(`` +
`<BoxType xmlns="http://ns.example.com/boxschema/">` +
`Box type A` +
`</BoxType>`),
}, {
XMLName: xml.Name{Space: "http://ns.example.com/boxschema/", Local: "author"},
InnerXML: []byte(`` +
`<Name xmlns="http://ns.example.com/boxschema/">` +
`J.J. Johnson` +
`</Name>`),
}},
Status: "HTTP/1.1 200 OK",
}, {
Prop: []Property{{
XMLName: xml.Name{Space: "http://ns.example.com/boxschema/", Local: "DingALing"},
}, {
XMLName: xml.Name{Space: "http://ns.example.com/boxschema/", Local: "Random"},
}},
Status: "HTTP/1.1 403 Forbidden",
ResponseDescription: "The user does not have access to the DingALing property.",
}},
}},
respdesc: "There has been an access violation error.",
wantXML: `` +
`<?xml version="1.0" encoding="UTF-8"?>` +
`<multistatus xmlns="DAV:" xmlns:B="http://ns.example.com/boxschema/">` +
` <response>` +
` <href>http://example.com/foo</href>` +
` <propstat>` +
` <prop>` +
` <B:bigbox><B:BoxType>Box type A</B:BoxType></B:bigbox>` +
` <B:author><B:Name>J.J. Johnson</B:Name></B:author>` +
` </prop>` +
` <status>HTTP/1.1 200 OK</status>` +
` </propstat>` +
` <propstat>` +
` <prop>` +
` <B:DingALing/>` +
` <B:Random/>` +
` </prop>` +
` <status>HTTP/1.1 403 Forbidden</status>` +
` <responsedescription>The user does not have access to the DingALing property.</responsedescription>` +
` </propstat>` +
` </response>` +
` <responsedescription>There has been an access violation error.</responsedescription>` +
`</multistatus>`,
wantCode: StatusMulti,
}, {
desc: "no response written",
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "no response written (with description)",
respdesc: "too bad",
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "empty multistatus with header",
writeHeader: true,
wantXML: `<multistatus xmlns="DAV:"></multistatus>`,
wantCode: StatusMulti,
}, {
desc: "bad: no href",
responses: []response{{
Propstat: []propstat{{
Prop: []Property{{
XMLName: xml.Name{
Space: "http://example.com/",
Local: "foo",
},
}},
Status: "HTTP/1.1 200 OK",
}},
}},
wantErr: errInvalidResponse,
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "bad: multiple hrefs and no status",
responses: []response{{
Href: []string{"http://example.com/foo", "http://example.com/bar"},
}},
wantErr: errInvalidResponse,
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "bad: one href and no propstat",
responses: []response{{
Href: []string{"http://example.com/foo"},
}},
wantErr: errInvalidResponse,
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "bad: status with one href and propstat",
responses: []response{{
Href: []string{"http://example.com/foo"},
Propstat: []propstat{{
Prop: []Property{{
XMLName: xml.Name{
Space: "http://example.com/",
Local: "foo",
},
}},
Status: "HTTP/1.1 200 OK",
}},
Status: "HTTP/1.1 200 OK",
}},
wantErr: errInvalidResponse,
// default of http.responseWriter
wantCode: http.StatusOK,
}, {
desc: "bad: multiple hrefs and propstat",
responses: []response{{
Href: []string{
"http://example.com/foo",
"http://example.com/bar",
},
Propstat: []propstat{{
Prop: []Property{{
XMLName: xml.Name{
Space: "http://example.com/",
Local: "foo",
},
}},
Status: "HTTP/1.1 200 OK",
}},
}},
wantErr: errInvalidResponse,
// default of http.responseWriter
wantCode: http.StatusOK,
}}
n := xmlNormalizer{omitWhitespace: true}
loop:
for _, tc := range testCases {
rec := httptest.NewRecorder()
w := MultistatusWriter{w: rec, ResponseDescription: tc.respdesc}
if tc.writeHeader {
if err := w.writeHeader(); err != nil {
t.Errorf("%s: got writeHeader error %v, want nil", tc.desc, err)
continue
}
}
for _, r := range tc.responses {
if err := w.write(&r); err != nil {
if err != tc.wantErr {
t.Errorf("%s: got write error %v, want %v",
tc.desc, err, tc.wantErr)
}
continue loop
}
}
if err := w.Close(); err != tc.wantErr {
t.Errorf("%s: got close error %v, want %v",
tc.desc, err, tc.wantErr)
continue
}
if rec.Code != tc.wantCode {
t.Errorf("%s: got HTTP status code %d, want %d\n",
tc.desc, rec.Code, tc.wantCode)
continue
}
gotXML := rec.Body.String()
eq, err := n.equalXML(strings.NewReader(gotXML), strings.NewReader(tc.wantXML))
if err != nil {
t.Errorf("%s: equalXML: %v", tc.desc, err)
continue
}
if !eq {
t.Errorf("%s: XML body\ngot %s\nwant %s", tc.desc, gotXML, tc.wantXML)
}
}
}
func TestReadProppatch(t *testing.T) {
ppStr := func(pps []Proppatch) string {
var outer []string
for _, pp := range pps {
var inner []string
for _, p := range pp.Props {
inner = append(inner, fmt.Sprintf("{XMLName: %q, Lang: %q, InnerXML: %q}",
p.XMLName, p.Lang, p.InnerXML))
}
outer = append(outer, fmt.Sprintf("{Remove: %t, Props: [%s]}",
pp.Remove, strings.Join(inner, ", ")))
}
return "[" + strings.Join(outer, ", ") + "]"
}
testCases := []struct {
desc string
input string
wantPP []Proppatch
wantStatus int
}{{
desc: "proppatch: section 9.2 (with simple property value)",
input: `` +
`<?xml version="1.0" encoding="utf-8" ?>` +
`<D:propertyupdate xmlns:D="DAV:"` +
` xmlns:Z="http://ns.example.com/z/">` +
` <D:set>` +
` <D:prop><Z:Authors>somevalue</Z:Authors></D:prop>` +
` </D:set>` +
` <D:remove>` +
` <D:prop><Z:Copyright-Owner/></D:prop>` +
` </D:remove>` +
`</D:propertyupdate>`,
wantPP: []Proppatch{{
Props: []Property{{
xml.Name{Space: "http://ns.example.com/z/", Local: "Authors"},
"",
[]byte(`somevalue`),
}},
}, {
Remove: true,
Props: []Property{{
xml.Name{Space: "http://ns.example.com/z/", Local: "Copyright-Owner"},
"",
nil,
}},
}},
}, {
desc: "proppatch: lang attribute on prop",
input: `` +
`<?xml version="1.0" encoding="utf-8" ?>` +
`<D:propertyupdate xmlns:D="DAV:">` +
` <D:set>` +
` <D:prop xml:lang="en">` +
` <foo xmlns="http://example.com/ns"/>` +
` </D:prop>` +
` </D:set>` +
`</D:propertyupdate>`,
wantPP: []Proppatch{{
Props: []Property{{
xml.Name{Space: "http://example.com/ns", Local: "foo"},
"en",
nil,
}},
}},
}, {
desc: "bad: remove with value",
input: `` +
`<?xml version="1.0" encoding="utf-8" ?>` +
`<D:propertyupdate xmlns:D="DAV:"` +
` xmlns:Z="http://ns.example.com/z/">` +
` <D:remove>` +
` <D:prop>` +
` <Z:Authors>` +
` <Z:Author>Jim Whitehead</Z:Author>` +
` </Z:Authors>` +
` </D:prop>` +
` </D:remove>` +
`</D:propertyupdate>`,
wantStatus: http.StatusBadRequest,
}, {
desc: "bad: empty propertyupdate",
input: `` +
`<?xml version="1.0" encoding="utf-8" ?>` +
`<D:propertyupdate xmlns:D="DAV:"` +
`</D:propertyupdate>`,
wantStatus: http.StatusBadRequest,
}, {
desc: "bad: empty prop",
input: `` +
`<?xml version="1.0" encoding="utf-8" ?>` +
`<D:propertyupdate xmlns:D="DAV:"` +
` xmlns:Z="http://ns.example.com/z/">` +
` <D:remove>` +
` <D:prop/>` +
` </D:remove>` +
`</D:propertyupdate>`,
wantStatus: http.StatusBadRequest,
}}
for _, tc := range testCases {
pp, status, err := readProppatch(strings.NewReader(tc.input))
if tc.wantStatus != 0 {
if err == nil {
t.Errorf("%s: got nil error, want non-nil", tc.desc)
continue
}
} else if err != nil {
t.Errorf("%s: %v", tc.desc, err)
continue
}
if status != tc.wantStatus {
t.Errorf("%s: got status %d, want %d", tc.desc, status, tc.wantStatus)
continue
}
if !reflect.DeepEqual(pp, tc.wantPP) || status != tc.wantStatus {
t.Errorf("%s: proppatch\ngot %v\nwant %v", tc.desc, ppStr(pp), ppStr(tc.wantPP))
}
}
}
func TestUnmarshalXMLValue(t *testing.T) {
testCases := []struct {
desc string
input string
wantVal string
}{{
desc: "simple char data",
input: "<root>foo</root>",
wantVal: "foo",
}, {
desc: "empty element",
input: "<root><foo/></root>",
wantVal: "<foo/>",
}, {
desc: "preserve namespace",
input: `<root><foo xmlns="bar"/></root>`,
wantVal: `<foo xmlns="bar"/>`,
}, {
desc: "preserve root element namespace",
input: `<root xmlns:bar="bar"><bar:foo/></root>`,
wantVal: `<foo xmlns="bar"/>`,
}, {
desc: "preserve whitespace",
input: "<root> \t </root>",
wantVal: " \t ",
}, {
desc: "preserve mixed content",
input: `<root xmlns="bar"> <foo>a<bam xmlns="baz"/> </foo> </root>`,
wantVal: ` <foo xmlns="bar">a<bam xmlns="baz"/> </foo> `,
}, {
desc: "section 9.2",
input: `` +
`<Z:Authors xmlns:Z="http://ns.example.com/z/">` +
` <Z:Author>Jim Whitehead</Z:Author>` +
` <Z:Author>Roy Fielding</Z:Author>` +
`</Z:Authors>`,
wantVal: `` +
` <Author xmlns="http://ns.example.com/z/">Jim Whitehead</Author>` +
` <Author xmlns="http://ns.example.com/z/">Roy Fielding</Author>`,
}, {
desc: "section 4.3.1 (mixed content)",
input: `` +
`<x:author ` +
` xmlns:x='http://example.com/ns' ` +
` xmlns:D="DAV:">` +
` <x:name>Jane Doe</x:name>` +
` <!-- Jane's contact info -->` +
` <x:uri type='email'` +
` added='2005-11-26'>mailto:jane.doe@example.com</x:uri>` +
` <x:uri type='web'` +
` added='2005-11-27'>http://www.example.com</x:uri>` +
` <x:notes xmlns:h='http://www.w3.org/1999/xhtml'>` +
` Jane has been working way <h:em>too</h:em> long on the` +
` long-awaited revision of <![CDATA[<RFC2518>]]>.` +
` </x:notes>` +
`</x:author>`,
wantVal: `` +
` <name xmlns="http://example.com/ns">Jane Doe</name>` +
` ` +
` <uri type='email'` +
` xmlns="http://example.com/ns" ` +
` added='2005-11-26'>mailto:jane.doe@example.com</uri>` +
` <uri added='2005-11-27'` +
` type='web'` +
` xmlns="http://example.com/ns">http://www.example.com</uri>` +
` <notes xmlns="http://example.com/ns" ` +
` xmlns:h="http://www.w3.org/1999/xhtml">` +
` Jane has been working way <h:em>too</h:em> long on the` +
` long-awaited revision of &lt;RFC2518&gt;.` +
` </notes>`,
}}
var n xmlNormalizer
for _, tc := range testCases {
d := xml.NewDecoder(strings.NewReader(tc.input))
var v xmlValue
if err := d.Decode(&v); err != nil {
t.Errorf("%s: got error %v, want nil", tc.desc, err)
continue
}
eq, err := n.equalXML(bytes.NewReader(v), strings.NewReader(tc.wantVal))
if err != nil {
t.Errorf("%s: equalXML: %v", tc.desc, err)
continue
}
if !eq {
t.Errorf("%s:\ngot %s\nwant %s", tc.desc, string(v), tc.wantVal)
}
}
}
// xmlNormalizer normalizes XML.
type xmlNormalizer struct {
// omitWhitespace instructs to ignore whitespace between element tags.
omitWhitespace bool
// omitComments instructs to ignore XML comments.
omitComments bool
}
// normalize writes the normalized XML content of r to w. It applies the
// following rules
//
// * Rename namespace prefixes according to an internal heuristic.
// * Remove unnecessary namespace declarations.
// * Sort attributes in XML start elements in lexical order of their
// fully qualified name.
// * Remove XML directives and processing instructions.
// * Remove CDATA between XML tags that only contains whitespace, if
// instructed to do so.
// * Remove comments, if instructed to do so.
//
func (n *xmlNormalizer) normalize(w io.Writer, r io.Reader) error {
d := xml.NewDecoder(r)
e := xml.NewEncoder(w)
for {
t, err := d.Token()
if err != nil {
if t == nil && err == io.EOF {
break
}
return err
}
switch val := t.(type) {
case xml.Directive, xml.ProcInst:
continue
case xml.Comment:
if n.omitComments {
continue
}
case xml.CharData:
if n.omitWhitespace && len(bytes.TrimSpace(val)) == 0 {
continue
}
case xml.StartElement:
start, _ := xml.CopyToken(val).(xml.StartElement)
attr := start.Attr[:0]
for _, a := range start.Attr {
if a.Name.Space == "xmlns" || a.Name.Local == "xmlns" {
continue
}
attr = append(attr, a)
}
sort.Sort(byName(attr))
start.Attr = attr
t = start
}
err = e.EncodeToken(t)
if err != nil {
return err
}
}
return e.Flush()
}
// equalXML tests for equality of the normalized XML contents of a and b.
func (n *xmlNormalizer) equalXML(a, b io.Reader) (bool, error) {
var buf bytes.Buffer
if err := n.normalize(&buf, a); err != nil {
return false, err
}
normA := buf.String()
buf.Reset()
if err := n.normalize(&buf, b); err != nil {
return false, err
}
normB := buf.String()
return normA == normB, nil
}
type byName []xml.Attr
func (a byName) Len() int { return len(a) }
func (a byName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byName) Less(i, j int) bool {
if a[i].Name.Space != a[j].Name.Space {
return a[i].Name.Space < a[j].Name.Space
}
return a[i].Name.Local < a[j].Name.Local
}