mirror of
https://github.com/1f349/go-webdav.git
synced 2025-04-04 19:25:06 +01:00
Compare commits
123 Commits
Author | SHA1 | Date | |
---|---|---|---|
6f60a899bf | |||
d28f08a32d | |||
f5b508b766 | |||
63f15c0ec6 | |||
906087cd59 | |||
|
3cc7466ac9 | ||
|
9d778f4072 | ||
|
93fee5bcf0 | ||
|
7f8c17ad71 | ||
|
810c51fa2d | ||
|
21f251fa1d | ||
|
ff8598015d | ||
|
ffd81465fd | ||
|
948f33c2fc | ||
|
381b8a3cee | ||
|
df447dc627 | ||
|
3ed9a4f052 | ||
|
25f1014ef2 | ||
|
ad1fe1c5a8 | ||
|
0ea114ec79 | ||
|
20fad80dff | ||
|
12d8b4bf62 | ||
|
fbcd08d64a | ||
|
f1d56f2437 | ||
|
71bd967b43 | ||
|
80d77a977a | ||
|
eaac65215b | ||
|
e3ba95cd77 | ||
|
5b5b542f2f | ||
|
ced348a58f | ||
|
b821d8c1ea | ||
|
790ebfc5f8 | ||
|
4493704689 | ||
|
b043bbd965 | ||
|
75d3041b41 | ||
|
751741d87e | ||
|
7e076258d6 | ||
|
174622c1eb | ||
|
d033e09835 | ||
|
379a418130 | ||
|
0e58dbb003 | ||
|
7d337ac048 | ||
|
dddaf279ed | ||
|
fc4ea1aae2 | ||
|
571eba7c02 | ||
|
b46cbafa6f | ||
|
0fb0a675ab | ||
|
7dd64908d2 | ||
|
46dbba12fe | ||
|
150f74a6f0 | ||
|
0456b28ba3 | ||
|
ac9af45270 | ||
|
d4d56c2707 | ||
|
5bfd6f54b2 | ||
|
54f2a6355b | ||
|
001e5953f7 | ||
|
561012d30f | ||
|
6f22a649ac | ||
|
dc63df9058 | ||
|
58dc8e4982 | ||
|
9adfd95fa9 | ||
|
4264d321a5 | ||
|
4a3cd0510f | ||
|
987c9eef0b | ||
|
e0764c06a3 | ||
|
db966a275c | ||
|
21aea26c70 | ||
|
d7891ce50c | ||
|
55a9274ba6 | ||
|
1c71a7a1c4 | ||
|
d0fc22a428 | ||
|
9bc7a8f15b | ||
|
03633121d9 | ||
|
13fa812f94 | ||
|
06ecb0e64c | ||
|
97e0b10b4f | ||
|
5d845721d8 | ||
|
1e99b70a62 | ||
|
a3e56141d9 | ||
|
9ed4abce57 | ||
|
38a35d3545 | ||
|
757a615e9f | ||
|
491af8e42c | ||
|
cabaf3268b | ||
|
b0c59cdea1 | ||
|
bc3faca3a0 | ||
|
a346d42f42 | ||
|
e971269ffb | ||
|
346cfadd34 | ||
|
303aef52f3 | ||
|
585b01a7a8 | ||
|
cdb0de3b99 | ||
|
6887b6b812 | ||
|
b5c6f8927c | ||
|
95a4ae783b | ||
|
8931e14cf6 | ||
|
d8a8af0448 | ||
|
3f8b212b0d | ||
|
8cc6542f1c | ||
|
46ebe58ac2 | ||
|
4e8c5effe3 | ||
|
8738a105fc | ||
|
25dfbaf95e | ||
|
6401d9ed45 | ||
|
7dafedd290 | ||
|
c4206ba616 | ||
|
52215c1690 | ||
|
106d4e1c88 | ||
|
9caa4ff356 | ||
|
85d2b222bb | ||
|
6d59672ed4 | ||
|
dc57b81662 | ||
|
0f6744ede8 | ||
|
2162596af8 | ||
|
6238e10e65 | ||
|
8efde26ef9 | ||
|
ed52608852 | ||
|
373663f9ee | ||
|
4316bbcd93 | ||
|
9cd3bb51b9 | ||
|
9e23289610 | ||
|
25df841e2b | ||
|
a4e0e81003 |
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: "https://web.libera.chat/gamja/#emersion"
|
||||
about: "Please ask questions in #emersion on Libera Chat"
|
12
.github/ISSUE_TEMPLATE/issue_template.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/issue_template.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
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
1
.gitignore
vendored
@ -12,3 +12,4 @@
|
||||
|
||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||
.glide/
|
||||
.idea/
|
||||
|
@ -1,6 +1,6 @@
|
||||
# go-webdav
|
||||
|
||||
[](https://godoc.org/github.com/emersion/go-webdav)
|
||||
[](https://pkg.go.dev/github.com/emersion/go-webdav)
|
||||
|
||||
A Go library for [WebDAV], [CalDAV] and [CardDAV].
|
||||
|
||||
|
104
caldav/caldav.go
104
caldav/caldav.go
@ -4,16 +4,71 @@
|
||||
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
|
||||
Path string
|
||||
Name string
|
||||
Description string
|
||||
MaxResourceSize int64
|
||||
SupportedComponentSet []string
|
||||
}
|
||||
|
||||
type CalendarCompRequest struct {
|
||||
@ -27,19 +82,30 @@ type CalendarCompRequest struct {
|
||||
}
|
||||
|
||||
type CompFilter struct {
|
||||
Name string
|
||||
Start, End time.Time
|
||||
Props []PropFilter
|
||||
Comps []CompFilter
|
||||
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
|
||||
TextMatch *TextMatch
|
||||
Name string
|
||||
IsNotDefined bool
|
||||
Start, End time.Time
|
||||
TextMatch *TextMatch
|
||||
ParamFilter []ParamFilter
|
||||
}
|
||||
|
||||
type TextMatch struct {
|
||||
Text string
|
||||
Text string
|
||||
NegateCondition bool
|
||||
}
|
||||
|
||||
type CalendarQuery struct {
|
||||
@ -47,9 +113,15 @@ type CalendarQuery struct {
|
||||
CompFilter CompFilter
|
||||
}
|
||||
|
||||
type CalendarObject struct {
|
||||
Path string
|
||||
ModTime time.Time
|
||||
ETag string
|
||||
Data *ical.Calendar
|
||||
type CalendarMultiGet struct {
|
||||
Paths []string
|
||||
CompRequest CalendarCompRequest
|
||||
}
|
||||
|
||||
type CalendarObject struct {
|
||||
Path string
|
||||
ModTime time.Time
|
||||
ContentLength int64
|
||||
ETag string
|
||||
Data *ical.Calendar
|
||||
}
|
||||
|
118
caldav/client.go
118
caldav/client.go
@ -2,6 +2,7 @@ package caldav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
@ -15,6 +16,12 @@ import (
|
||||
"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
|
||||
@ -34,9 +41,9 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
|
||||
return &Client{wc, ic}, nil
|
||||
}
|
||||
|
||||
func (c *Client) FindCalendarHomeSet(principal string) (string, error) {
|
||||
propfind := internal.NewPropNamePropfind(calendarHomeSetName)
|
||||
resp, err := c.ic.PropfindFlat(principal, propfind)
|
||||
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
|
||||
}
|
||||
@ -49,14 +56,15 @@ func (c *Client) FindCalendarHomeSet(principal string) (string, error) {
|
||||
return prop.Href.Path, nil
|
||||
}
|
||||
|
||||
func (c *Client) FindCalendars(calendarHomeSet string) ([]Calendar, error) {
|
||||
propfind := internal.NewPropNamePropfind(
|
||||
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(calendarHomeSet, internal.DepthOne, propfind)
|
||||
ms, err := c.ic.PropFind(ctx, calendarHomeSet, internal.DepthOne, propfind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -94,11 +102,22 @@ func (c *Client) FindCalendars(calendarHomeSet string) ([]Calendar, error) {
|
||||
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,
|
||||
Path: path,
|
||||
Name: dispName.Name,
|
||||
Description: desc.Description,
|
||||
MaxResourceSize: maxResSize.Size,
|
||||
SupportedComponentSet: compNames,
|
||||
})
|
||||
}
|
||||
|
||||
@ -156,7 +175,7 @@ func encodeCompFilter(filter *CompFilter) *compFilter {
|
||||
return &encoded
|
||||
}
|
||||
|
||||
func decodeCalendarObjectList(ms *internal.Multistatus) ([]CalendarObject, error) {
|
||||
func decodeCalendarObjectList(ms *internal.MultiStatus) ([]CalendarObject, error) {
|
||||
addrs := make([]CalendarObject, 0, len(ms.Responses))
|
||||
for _, resp := range ms.Responses {
|
||||
path, err := resp.Path()
|
||||
@ -179,6 +198,11 @@ func decodeCalendarObjectList(ms *internal.Multistatus) ([]CalendarObject, error
|
||||
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 {
|
||||
@ -186,17 +210,18 @@ func decodeCalendarObjectList(ms *internal.Multistatus) ([]CalendarObject, error
|
||||
}
|
||||
|
||||
addrs = append(addrs, CalendarObject{
|
||||
Path: path,
|
||||
ModTime: time.Time(getLastMod.LastModified),
|
||||
ETag: string(getETag.ETag),
|
||||
Data: data,
|
||||
Path: path,
|
||||
ModTime: time.Time(getLastMod.LastModified),
|
||||
ContentLength: getContentLength.Length,
|
||||
ETag: string(getETag.ETag),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
func (c *Client) QueryCalendar(calendar string, query *CalendarQuery) ([]CalendarObject, error) {
|
||||
func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *CalendarQuery) ([]CalendarObject, error) {
|
||||
propReq, err := encodeCalendarReq(&query.CompRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -210,7 +235,7 @@ func (c *Client) QueryCalendar(calendar string, query *CalendarQuery) ([]Calenda
|
||||
}
|
||||
req.Header.Add("Depth", "1")
|
||||
|
||||
ms, err := c.ic.DoMultiStatus(req)
|
||||
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -218,22 +243,61 @@ func (c *Client) QueryCalendar(calendar string, query *CalendarQuery) ([]Calenda
|
||||
return decodeCalendarObjectList(ms)
|
||||
}
|
||||
|
||||
func populateCalendarObject(co *CalendarObject, resp *http.Response) error {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
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 := resp.Header.Get("ETag"); etag != "" {
|
||||
if etag := h.Get("ETag"); etag != "" {
|
||||
etag, err := strconv.Unquote(etag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
co.ETag = etag
|
||||
}
|
||||
if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
|
||||
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
|
||||
@ -244,14 +308,14 @@ func populateCalendarObject(co *CalendarObject, resp *http.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCalendarObject(path string) (*CalendarObject, error) {
|
||||
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)
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -274,13 +338,13 @@ func (c *Client) GetCalendarObject(path string) (*CalendarObject, error) {
|
||||
Path: resp.Request.URL.Path,
|
||||
Data: cal,
|
||||
}
|
||||
if err := populateCalendarObject(co, resp); err != nil {
|
||||
if err := populateCalendarObject(co, resp.Header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return co, nil
|
||||
}
|
||||
|
||||
func (c *Client) PutCalendarObject(path string, cal *ical.Calendar) (*CalendarObject, error) {
|
||||
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
|
||||
@ -298,14 +362,14 @@ func (c *Client) PutCalendarObject(path string, cal *ical.Calendar) (*CalendarOb
|
||||
}
|
||||
req.Header.Set("Content-Type", ical.MIMEType)
|
||||
|
||||
resp, err := c.ic.Do(req)
|
||||
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); err != nil {
|
||||
if err := populateCalendarObject(co, resp.Header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return co, nil
|
||||
|
@ -2,6 +2,7 @@ package caldav
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
@ -12,11 +13,16 @@ 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"}
|
||||
maxResourceSizeName = xml.Name{namespace, "max-resource-size"}
|
||||
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"}
|
||||
|
||||
calendarName = xml.Name{namespace, "calendar"}
|
||||
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
|
||||
@ -25,6 +31,10 @@ type calendarHomeSet struct {
|
||||
Href internal.Href `xml:"DAV: href"`
|
||||
}
|
||||
|
||||
func (a *calendarHomeSet) GetXMLName() xml.Name {
|
||||
return calendarHomeSetName
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4791#section-5.2.1
|
||||
type calendarDescription struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-description"`
|
||||
@ -37,6 +47,12 @@ type supportedCalendarData struct {
|
||||
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"`
|
||||
@ -60,6 +76,15 @@ type calendarQuery struct {
|
||||
// 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"`
|
||||
@ -78,19 +103,49 @@ type compFilter struct {
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
TimeRange *timeRange `xml:"time-range,omitempty"`
|
||||
TextMatch *textMatch `xml:"text-match,omitempty"`
|
||||
// TODO: param-filter
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// TODO: collation, negate-condition
|
||||
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
|
||||
@ -151,3 +206,32 @@ 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?
|
||||
}
|
||||
|
205
caldav/match.go
Normal file
205
caldav/match.go
Normal file
@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
311
caldav/match_test.go
Normal file
311
caldav/match_test.go
Normal file
@ -0,0 +1,311 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
773
caldav/server.go
Normal file
773
caldav/server.go
Normal file
@ -0,0 +1,773 @@
|
||||
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(¶mEl)
|
||||
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},
|
||||
},
|
||||
}
|
||||
}
|
235
caldav/server_test.go
Normal file
235
caldav/server_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
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
|
||||
}
|
@ -7,8 +7,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
)
|
||||
|
||||
var CapabilityAddressBook = webdav.Capability("addressbook")
|
||||
|
||||
func NewAddressBookHomeSet(path string) webdav.BackendSuppliedHomeSet {
|
||||
return &addressbookHomeSet{Href: internal.Href{Path: path}}
|
||||
}
|
||||
|
||||
type AddressDataType struct {
|
||||
ContentType string
|
||||
Version string
|
||||
@ -94,8 +102,23 @@ type AddressBookMultiGet struct {
|
||||
}
|
||||
|
||||
type AddressObject struct {
|
||||
Path string
|
||||
ModTime time.Time
|
||||
ETag string
|
||||
Card vcard.Card
|
||||
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
|
||||
}
|
||||
|
242
carddav/carddav_test.go
Normal file
242
carddav/carddav_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -2,9 +2,9 @@ package carddav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -16,36 +16,10 @@ import (
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
)
|
||||
|
||||
// Discover performs a DNS-based CardDAV service discovery as described in
|
||||
// RFC 6352 section 11. It returns the URL to the CardDAV server.
|
||||
func Discover(domain string) (string, error) {
|
||||
// Only lookup carddavs (not carddav), plaintext connections are insecure
|
||||
_, addrs, err := net.LookupSRV("carddavs", "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("carddav: domain doesn't have an SRV record")
|
||||
}
|
||||
addr := addrs[0]
|
||||
|
||||
target := strings.TrimSuffix(addr.Target, ".")
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("carddav: empty target in SRV record")
|
||||
}
|
||||
|
||||
u := url.URL{Scheme: "https"}
|
||||
if addr.Port == 443 {
|
||||
u.Host = target
|
||||
} else {
|
||||
u.Host = fmt.Sprintf("%v:%v", target, addr.Port)
|
||||
}
|
||||
return u.String(), nil
|
||||
// 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.
|
||||
@ -67,8 +41,8 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
|
||||
return &Client{wc, ic}, nil
|
||||
}
|
||||
|
||||
func (c *Client) HasSupport() error {
|
||||
classes, _, err := c.ic.Options("")
|
||||
func (c *Client) HasSupport(ctx context.Context) error {
|
||||
classes, _, err := c.ic.Options(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -79,9 +53,9 @@ func (c *Client) HasSupport() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) FindAddressBookHomeSet(principal string) (string, error) {
|
||||
propfind := internal.NewPropNamePropfind(addressBookHomeSetName)
|
||||
resp, err := c.ic.PropfindFlat(principal, propfind)
|
||||
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
|
||||
}
|
||||
@ -102,15 +76,15 @@ func decodeSupportedAddressData(supported *supportedAddressData) []AddressDataTy
|
||||
return l
|
||||
}
|
||||
|
||||
func (c *Client) FindAddressBooks(addressBookHomeSet string) ([]AddressBook, error) {
|
||||
propfind := internal.NewPropNamePropfind(
|
||||
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(addressBookHomeSet, internal.DepthOne, propfind)
|
||||
ms, err := c.ic.PropFind(ctx, addressBookHomeSet, internal.DepthOne, propfind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -223,7 +197,7 @@ func encodeTextMatch(tm *TextMatch) *textMatch {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAddressList(ms *internal.Multistatus) ([]AddressObject, error) {
|
||||
func decodeAddressList(ms *internal.MultiStatus) ([]AddressObject, error) {
|
||||
addrs := make([]AddressObject, 0, len(ms.Responses))
|
||||
for _, resp := range ms.Responses {
|
||||
path, err := resp.Path()
|
||||
@ -246,6 +220,11 @@ func decodeAddressList(ms *internal.Multistatus) ([]AddressObject, error) {
|
||||
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 {
|
||||
@ -253,17 +232,18 @@ func decodeAddressList(ms *internal.Multistatus) ([]AddressObject, error) {
|
||||
}
|
||||
|
||||
addrs = append(addrs, AddressObject{
|
||||
Path: path,
|
||||
ModTime: time.Time(getLastMod.LastModified),
|
||||
ETag: string(getETag.ETag),
|
||||
Card: card,
|
||||
Path: path,
|
||||
ModTime: time.Time(getLastMod.LastModified),
|
||||
ContentLength: getContentLength.Length,
|
||||
ETag: string(getETag.ETag),
|
||||
Card: card,
|
||||
})
|
||||
}
|
||||
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
|
||||
func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
|
||||
propReq, err := encodeAddressPropReq(&query.DataRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -289,7 +269,7 @@ func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) (
|
||||
|
||||
req.Header.Add("Depth", "1")
|
||||
|
||||
ms, err := c.ic.DoMultiStatus(req)
|
||||
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -297,7 +277,7 @@ func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) (
|
||||
return decodeAddressList(ms)
|
||||
}
|
||||
|
||||
func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
|
||||
func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
|
||||
propReq, err := encodeAddressPropReq(&multiGet.DataRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -305,7 +285,7 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
|
||||
|
||||
addressbookMultiget := addressbookMultiget{Prop: propReq}
|
||||
|
||||
if multiGet == nil || len(multiGet.Paths) == 0 {
|
||||
if len(multiGet.Paths) == 0 {
|
||||
href := internal.Href{Path: path}
|
||||
addressbookMultiget.Hrefs = []internal.Href{href}
|
||||
} else {
|
||||
@ -322,7 +302,7 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
|
||||
|
||||
req.Header.Add("Depth", "1")
|
||||
|
||||
ms, err := c.ic.DoMultiStatus(req)
|
||||
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -330,22 +310,29 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
|
||||
return decodeAddressList(ms)
|
||||
}
|
||||
|
||||
func populateAddressObject(ao *AddressObject, resp *http.Response) error {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
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 := resp.Header.Get("ETag"); etag != "" {
|
||||
if etag := h.Get("ETag"); etag != "" {
|
||||
etag, err := strconv.Unquote(etag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ao.ETag = etag
|
||||
}
|
||||
if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
|
||||
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
|
||||
@ -356,14 +343,14 @@ func populateAddressObject(ao *AddressObject, resp *http.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAddressObject(path string) (*AddressObject, error) {
|
||||
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)
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -386,13 +373,13 @@ func (c *Client) GetAddressObject(path string) (*AddressObject, error) {
|
||||
Path: resp.Request.URL.Path,
|
||||
Card: card,
|
||||
}
|
||||
if err := populateAddressObject(ao, resp); err != nil {
|
||||
if err := populateAddressObject(ao, resp.Header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ao, nil
|
||||
}
|
||||
|
||||
func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject, error) {
|
||||
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
|
||||
@ -417,15 +404,69 @@ func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject,
|
||||
}
|
||||
req.Header.Set("Content-Type", vcard.MIMEType)
|
||||
|
||||
resp, err := c.ic.Do(req)
|
||||
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); err != nil {
|
||||
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
|
||||
}
|
||||
|
@ -29,6 +29,10 @@ type addressbookHomeSet struct {
|
||||
Href internal.Href `xml:"DAV: href"`
|
||||
}
|
||||
|
||||
func (a *addressbookHomeSet) GetXMLName() xml.Name {
|
||||
return addressBookHomeSetName
|
||||
}
|
||||
|
||||
type addressbookDescription struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:carddav addressbook-description"`
|
||||
Description string `xml:",chardata"`
|
||||
@ -207,3 +211,11 @@ func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
|
||||
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?
|
||||
}
|
||||
|
172
carddav/match.go
Normal file
172
carddav/match.go
Normal file
@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
633
carddav/match_test.go
Normal file
633
carddav/match_test.go
Normal file
@ -0,0 +1,633 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -2,31 +2,50 @@ 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"
|
||||
)
|
||||
|
||||
// TODO: add support for multiple address books
|
||||
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 {
|
||||
AddressBook() (*AddressBook, error)
|
||||
GetAddressObject(path string, req *AddressDataRequest) (*AddressObject, error)
|
||||
ListAddressObjects(req *AddressDataRequest) ([]AddressObject, error)
|
||||
QueryAddressObjects(query *AddressBookQuery) ([]AddressObject, error)
|
||||
PutAddressObject(path string, card vcard.Card) (loc string, err error)
|
||||
DeleteAddressObject(path string) error
|
||||
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.
|
||||
@ -36,13 +55,27 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/.well-known/carddav" {
|
||||
principalPath, err := h.Backend.CurrentUserPrincipal(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "carddav: failed to determine current user principal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, principalPath, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch r.Method {
|
||||
case "REPORT":
|
||||
err = h.handleReport(w, r)
|
||||
default:
|
||||
b := backend{h.Backend}
|
||||
hh := internal.Handler{&b}
|
||||
b := backend{
|
||||
Backend: h.Backend,
|
||||
Prefix: strings.TrimSuffix(h.Prefix, "/"),
|
||||
}
|
||||
hh := internal.Handler{Backend: &b}
|
||||
hh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@ -58,9 +91,9 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
|
||||
if report.Query != nil {
|
||||
return h.handleQuery(w, report.Query)
|
||||
return h.handleQuery(r, w, report.Query)
|
||||
} else if report.Multiget != nil {
|
||||
return h.handleMultiget(w, report.Multiget)
|
||||
return h.handleMultiget(r.Context(), w, report.Multiget)
|
||||
}
|
||||
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: expected addressbook-query or addressbook-multiget element in REPORT request")
|
||||
}
|
||||
@ -120,7 +153,7 @@ func decodeAddressDataReq(addressData *addressDataReq) (*AddressDataRequest, err
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleQuery(w http.ResponseWriter, query *addressbookQuery) error {
|
||||
func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *addressbookQuery) error {
|
||||
var q AddressBookQuery
|
||||
if query.Prop != nil {
|
||||
var addressData addressDataReq
|
||||
@ -137,42 +170,45 @@ func (h *Handler) handleQuery(w http.ResponseWriter, query *addressbookQuery) er
|
||||
for _, el := range query.Filter.Props {
|
||||
pf, err := decodePropFilter(&el)
|
||||
if err != nil {
|
||||
return &internal.HTTPError{http.StatusBadRequest, err}
|
||||
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())
|
||||
return internal.ServeMultiStatus(w, internal.NewMultiStatus())
|
||||
}
|
||||
}
|
||||
|
||||
aos, err := h.Backend.QueryAddressObjects(&q)
|
||||
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{h.Backend}
|
||||
propfind := internal.Propfind{
|
||||
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(&propfind, &ao)
|
||||
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)
|
||||
ms := internal.NewMultiStatus(resps...)
|
||||
return internal.ServeMultiStatus(w, ms)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMultiget(w http.ResponseWriter, multiget *addressbookMultiget) error {
|
||||
func (h *Handler) handleMultiget(ctx context.Context, w http.ResponseWriter, multiget *addressbookMultiget) error {
|
||||
var dataReq AddressDataRequest
|
||||
if multiget.Prop != nil {
|
||||
var addressData addressDataReq
|
||||
@ -188,43 +224,71 @@ func (h *Handler) handleMultiget(w http.ResponseWriter, multiget *addressbookMul
|
||||
|
||||
var resps []internal.Response
|
||||
for _, href := range multiget.Hrefs {
|
||||
ao, err := h.Backend.GetAddressObject(href.Path, &dataReq)
|
||||
ao, err := h.Backend.GetAddressObject(ctx, href.Path, &dataReq)
|
||||
if err != nil {
|
||||
return err // TODO: create internal.Response with error
|
||||
resp := internal.NewErrorResponse(href.Path, err)
|
||||
resps = append(resps, *resp)
|
||||
continue
|
||||
}
|
||||
|
||||
b := backend{h.Backend}
|
||||
propfind := internal.Propfind{
|
||||
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(&propfind, ao)
|
||||
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)
|
||||
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 r.URL.Path == "/" {
|
||||
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.URL.Path, &dataReq)
|
||||
_, 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 {
|
||||
@ -242,21 +306,25 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e
|
||||
}
|
||||
|
||||
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
||||
if r.URL.Path == "/" {
|
||||
return &internal.HTTPError{Code: http.StatusMethodNotAllowed}
|
||||
}
|
||||
|
||||
var dataReq AddressDataRequest
|
||||
if r.Method != http.MethodHead {
|
||||
dataReq.AllProp = true
|
||||
}
|
||||
ao, err := b.Backend.GetAddressObject(r.URL.Path, &dataReq)
|
||||
ao, err := b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", vcard.MIMEType)
|
||||
// TODO: set ETag, Last-Modified
|
||||
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)
|
||||
@ -264,95 +332,231 @@ func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth internal.Depth) (*internal.Multistatus, error) {
|
||||
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
|
||||
if r.URL.Path == "/" {
|
||||
ab, err := b.Backend.AddressBook()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := b.propfindAddressBook(propfind, ab)
|
||||
switch resType {
|
||||
case resourceTypeRoot:
|
||||
resp, err := b.propFindRoot(r.Context(), propfind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resps = append(resps, *resp)
|
||||
|
||||
if depth != internal.DepthZero {
|
||||
aos, err := b.Backend.ListAddressObjects(&dataReq)
|
||||
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
|
||||
}
|
||||
|
||||
for _, ao := range aos {
|
||||
resp, err := b.propfindAddressObject(propfind, &ao)
|
||||
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_...)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ao, err := b.Backend.GetAddressObject(r.URL.Path, &dataReq)
|
||||
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(propfind, ao)
|
||||
resp, err := b.propFindAddressObject(r.Context(), propfind, ao)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resps = append(resps, *resp)
|
||||
}
|
||||
|
||||
return internal.NewMultistatus(resps...), nil
|
||||
return internal.NewMultiStatus(resps...), nil
|
||||
}
|
||||
|
||||
func (b *backend) propfindAddressBook(propfind *internal.Propfind, ab *AddressBook) (*internal.Response, error) {
|
||||
props := map[xml.Name]internal.PropfindFunc{
|
||||
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return internal.NewResourceType(internal.CollectionName, addressBookName), nil
|
||||
},
|
||||
internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.DisplayName{Name: ab.Name}, nil
|
||||
},
|
||||
addressBookDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &addressbookDescription{Description: ab.Description}, nil
|
||||
},
|
||||
supportedAddressDataName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &supportedAddressData{
|
||||
Types: []addressDataType{
|
||||
{ContentType: vcard.MIMEType, Version: "3.0"},
|
||||
{ContentType: vcard.MIMEType, Version: "4.0"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
// TODO: this is a principal property
|
||||
addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &addressbookHomeSet{Href: internal.Href{Path: "/"}}, nil
|
||||
},
|
||||
// TODO: this should be set on all resources
|
||||
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: "/"}}, nil
|
||||
},
|
||||
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] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &maxResourceSize{Size: ab.MaxResourceSize}, nil
|
||||
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 internal.NewPropfindResponse("/", propfind, props)
|
||||
return resps, nil
|
||||
}
|
||||
|
||||
func (b *backend) propfindAddressObject(propfind *internal.Propfind, ao *AddressObject) (*internal.Response, error) {
|
||||
props := map[xml.Name]internal.PropfindFunc{
|
||||
internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetContentType{Type: vcard.MIMEType}, 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
|
||||
@ -364,67 +568,191 @@ func (b *backend) propfindAddressObject(propfind *internal.Propfind, ao *Address
|
||||
},
|
||||
}
|
||||
|
||||
if ao.ContentLength > 0 {
|
||||
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
|
||||
Length: ao.ContentLength,
|
||||
})
|
||||
}
|
||||
if !ao.ModTime.IsZero() {
|
||||
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetLastModified{LastModified: internal.Time(ao.ModTime)}, nil
|
||||
}
|
||||
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
|
||||
LastModified: internal.Time(ao.ModTime),
|
||||
})
|
||||
}
|
||||
|
||||
if ao.ETag != "" {
|
||||
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetETag{ETag: internal.ETag(ao.ETag)}, nil
|
||||
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 internal.NewPropfindResponse(ao.Path, propfind, props)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (b *backend) Proppatch(r *http.Request, update *internal.Propertyupdate) (*internal.Response, error) {
|
||||
// TODO: return a failed Response instead
|
||||
// TODO: support PROPPATCH for address books
|
||||
return nil, internal.HTTPErrorf(http.StatusForbidden, "carddav: PROPPATCH is unsupported")
|
||||
}
|
||||
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
|
||||
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
|
||||
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
|
||||
|
||||
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
|
||||
// TODO: add support for If-None-Match and If-Match
|
||||
opts := PutAddressObjectOptions{
|
||||
IfNoneMatch: ifNoneMatch,
|
||||
IfMatch: ifMatch,
|
||||
}
|
||||
|
||||
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err)
|
||||
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err)
|
||||
}
|
||||
if t != vcard.MIMEType {
|
||||
// TODO: send CARDDAV:supported-address-data error
|
||||
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: unsupporetd Content-Type %q", t)
|
||||
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 nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
|
||||
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
|
||||
}
|
||||
|
||||
// TODO: add support for the CARDDAV:no-uid-conflict error
|
||||
loc, err := b.Backend.PutAddressObject(r.URL.Path, card)
|
||||
ao, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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)
|
||||
}
|
||||
|
||||
return &internal.Href{Path: loc}, nil
|
||||
// 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.DeleteAddressObject(r.URL.Path)
|
||||
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 {
|
||||
return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation unsupported")
|
||||
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) {
|
||||
panic("TODO")
|
||||
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) {
|
||||
panic("TODO")
|
||||
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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
125
client.go
125
client.go
@ -1,6 +1,7 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -39,6 +40,11 @@ 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 {
|
||||
@ -47,10 +53,13 @@ func NewClient(c HTTPClient, endpoint string) (*Client, error) {
|
||||
return &Client{ic}, nil
|
||||
}
|
||||
|
||||
func (c *Client) FindCurrentUserPrincipal() (string, error) {
|
||||
propfind := internal.NewPropNamePropfind(internal.CurrentUserPrincipalName)
|
||||
// FindCurrentUserPrincipal finds the current user's principal path.
|
||||
func (c *Client) FindCurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
propfind := internal.NewPropNamePropFind(internal.CurrentUserPrincipalName)
|
||||
|
||||
resp, err := c.ic.PropfindFlat("", propfind)
|
||||
// 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
|
||||
}
|
||||
@ -66,7 +75,7 @@ func (c *Client) FindCurrentUserPrincipal() (string, error) {
|
||||
return prop.Href.Path, nil
|
||||
}
|
||||
|
||||
var fileInfoPropfind = internal.NewPropNamePropfind(
|
||||
var fileInfoPropFind = internal.NewPropNamePropFind(
|
||||
internal.ResourceTypeName,
|
||||
internal.GetContentLengthName,
|
||||
internal.GetLastModifiedName,
|
||||
@ -86,6 +95,7 @@ func fileInfoFromResponse(resp *internal.Response) (*FileInfo, error) {
|
||||
if err := resp.DecodeProp(&resType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resType.Is(internal.CollectionName) {
|
||||
fi.IsDir = true
|
||||
} else {
|
||||
@ -94,11 +104,6 @@ func fileInfoFromResponse(resp *internal.Response) (*FileInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var getMod internal.GetLastModified
|
||||
if err := resp.DecodeProp(&getMod); err != nil && !internal.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var getType internal.GetContentType
|
||||
if err := resp.DecodeProp(&getType); err != nil && !internal.IsNotFound(err) {
|
||||
return nil, err
|
||||
@ -110,29 +115,36 @@ func fileInfoFromResponse(resp *internal.Response) (*FileInfo, error) {
|
||||
}
|
||||
|
||||
fi.Size = getLen.Length
|
||||
fi.ModTime = time.Time(getMod.LastModified)
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) Stat(name string) (*FileInfo, error) {
|
||||
resp, err := c.ic.PropfindFlat(name, fileInfoPropfind)
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (c *Client) Open(name string) (io.ReadCloser, error) {
|
||||
// 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)
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -140,13 +152,14 @@ func (c *Client) Open(name string) (io.ReadCloser, error) {
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) Readdir(name string, recursive bool) ([]FileInfo, error) {
|
||||
// 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(name, depth, fileInfoPropfind)
|
||||
ms, err := c.ic.PropFind(ctx, name, depth, fileInfoPropFind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -179,7 +192,8 @@ func (fw *fileWriter) Close() error {
|
||||
return <-fw.done
|
||||
}
|
||||
|
||||
func (c *Client) Create(name string) (io.WriteCloser, error) {
|
||||
// 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)
|
||||
@ -190,55 +204,98 @@ func (c *Client) Create(name string) (io.WriteCloser, error) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.ic.Do(req)
|
||||
done <- err
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
return &fileWriter{pw, done}, nil
|
||||
}
|
||||
|
||||
func (c *Client) RemoveAll(name string) error {
|
||||
// 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
|
||||
}
|
||||
|
||||
_, err = c.ic.Do(req)
|
||||
return err
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Mkdir(name string) error {
|
||||
// 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
|
||||
}
|
||||
|
||||
_, err = c.ic.Do(req)
|
||||
return err
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) CopyAll(name, dest string, overwrite bool) error {
|
||||
// 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
|
||||
}
|
||||
|
||||
req.Header.Set("Destination", c.ic.ResolveHref(dest).String())
|
||||
req.Header.Set("Overwrite", internal.FormatOverwrite(overwrite))
|
||||
depth := internal.DepthInfinity
|
||||
if options.NoRecursive {
|
||||
depth = internal.DepthZero
|
||||
}
|
||||
|
||||
_, err = c.ic.Do(req)
|
||||
return err
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) MoveAll(name, dest string, overwrite bool) error {
|
||||
// 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(overwrite))
|
||||
req.Header.Set("Overwrite", internal.FormatOverwrite(!options.NoOverwrite))
|
||||
|
||||
_, err = c.ic.Do(req)
|
||||
return err
|
||||
resp, err := c.ic.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
32
elements.go
Normal file
32
elements.go
Normal file
@ -0,0 +1,32 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
principalName = xml.Name{"DAV:", "principal"}
|
||||
principalAlternateURISetName = xml.Name{"DAV:", "alternate-URI-set"}
|
||||
principalURLName = xml.Name{"DAV:", "principal-URL"}
|
||||
groupMembershipName = xml.Name{"DAV:", "group-membership"}
|
||||
)
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc3744#section-4.1
|
||||
type principalAlternateURISet struct {
|
||||
XMLName xml.Name `xml:"DAV: alternate-URI-set"`
|
||||
Hrefs []internal.Href `xml:"href"`
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc3744#section-4.2
|
||||
type principalURL struct {
|
||||
XMLName xml.Name `xml:"DAV: principal-URL"`
|
||||
Href internal.Href `xml:"href"`
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc3744#section-4.4
|
||||
type groupMembership struct {
|
||||
XMLName xml.Name `xml:"DAV: group-membership"`
|
||||
Hrefs []internal.Href `xml:"href"`
|
||||
}
|
125
fs_local.go
125
fs_local.go
@ -1,6 +1,7 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@ -13,8 +14,11 @@ import (
|
||||
"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")
|
||||
@ -34,7 +38,7 @@ func (fs LocalFileSystem) externalPath(name string) (string, error) {
|
||||
return "/" + filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Open(name string) (io.ReadCloser, error) {
|
||||
func (fs LocalFileSystem) Open(ctx context.Context, name string) (io.ReadCloser, error) {
|
||||
p, err := fs.localPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -59,19 +63,31 @@ func fileInfoFromOS(p string, fi os.FileInfo) *FileInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Stat(name string) (*FileInfo, error) {
|
||||
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, err
|
||||
return nil, errFromOS(err)
|
||||
}
|
||||
return fileInfoFromOS(name, fi), nil
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Readdir(name string, recursive bool) ([]FileInfo, error) {
|
||||
func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) {
|
||||
path, err := fs.localPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -95,18 +111,60 @@ func (fs LocalFileSystem) Readdir(name string, recursive bool) ([]FileInfo, erro
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return l, err
|
||||
return l, errFromOS(err)
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Create(name string) (io.WriteCloser, error) {
|
||||
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, err
|
||||
return nil, false, err
|
||||
}
|
||||
return os.Create(p)
|
||||
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(name string) error {
|
||||
func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error {
|
||||
p, err := fs.localPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -115,31 +173,32 @@ func (fs LocalFileSystem) RemoveAll(name string) error {
|
||||
// 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 err
|
||||
return errFromOS(err)
|
||||
}
|
||||
|
||||
return os.RemoveAll(p)
|
||||
return errFromOS(os.RemoveAll(p))
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Mkdir(name string) error {
|
||||
func (fs LocalFileSystem) Mkdir(ctx context.Context, name string) error {
|
||||
p, err := fs.localPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Mkdir(p, 0755)
|
||||
return errFromOS(os.Mkdir(p, 0755))
|
||||
}
|
||||
|
||||
func copyRegularFile(src, dst string, perm os.FileMode) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
return errFromOS(err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
// TODO: send http.StatusConflict on os.IsNotExist
|
||||
return err
|
||||
if os.IsNotExist(err) {
|
||||
return NewHTTPError(http.StatusConflict, err)
|
||||
} else if err != nil {
|
||||
return errFromOS(err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
@ -150,7 +209,7 @@ func copyRegularFile(src, dst string, perm os.FileMode) error {
|
||||
return dstFile.Close()
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) Copy(src, dst string, recursive, overwrite bool) (created bool, err error) {
|
||||
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
|
||||
@ -165,21 +224,21 @@ func (fs LocalFileSystem) Copy(src, dst string, recursive, overwrite bool) (crea
|
||||
|
||||
srcInfo, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
srcPerm := srcInfo.Mode() & os.ModePerm
|
||||
|
||||
if _, err := os.Stat(dstPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
created = true
|
||||
} else {
|
||||
if !overwrite {
|
||||
return false, os.ErrExist
|
||||
if options.NoOverwrite {
|
||||
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
|
||||
}
|
||||
if err := os.RemoveAll(dstPath); err != nil {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,7 +249,7 @@ func (fs LocalFileSystem) Copy(src, dst string, recursive, overwrite bool) (crea
|
||||
|
||||
if fi.IsDir() {
|
||||
if err := os.Mkdir(dstPath, srcPerm); err != nil {
|
||||
return err
|
||||
return errFromOS(err)
|
||||
}
|
||||
} else {
|
||||
if err := copyRegularFile(srcPath, dstPath, srcPerm); err != nil {
|
||||
@ -198,19 +257,19 @@ func (fs LocalFileSystem) Copy(src, dst string, recursive, overwrite bool) (crea
|
||||
}
|
||||
}
|
||||
|
||||
if fi.IsDir() && !recursive {
|
||||
if fi.IsDir() && options.NoRecursive {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (fs LocalFileSystem) MoveAll(src, dst string, overwrite bool) (created bool, err error) {
|
||||
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
|
||||
@ -222,23 +281,21 @@ func (fs LocalFileSystem) MoveAll(src, dst string, overwrite bool) (created bool
|
||||
|
||||
if _, err := os.Stat(dstPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
created = true
|
||||
} else {
|
||||
if !overwrite {
|
||||
return false, os.ErrExist
|
||||
if options.NoOverwrite {
|
||||
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
|
||||
}
|
||||
if err := os.RemoveAll(dstPath); err != nil {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(srcPath, dstPath); err != nil {
|
||||
return false, err
|
||||
return false, errFromOS(err)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
var _ FileSystem = LocalFileSystem("")
|
||||
|
4
go.mod
4
go.mod
@ -3,6 +3,6 @@ module github.com/emersion/go-webdav
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e
|
||||
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
|
||||
)
|
||||
|
10
go.sum
10
go.sum
@ -1,4 +1,6 @@
|
||||
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e h1:YGM1sI7edZOt8KAfX9Miq/X99d2QXdgjkJ7vN4HjxAA=
|
||||
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM=
|
||||
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7 h1:SE+tcd+0kn0cT4MqTo66gmkjqWHF1Z+Yha5/rhLs/H8=
|
||||
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
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=
|
||||
|
@ -2,10 +2,12 @@ package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
@ -13,6 +15,42 @@ import (
|
||||
"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)
|
||||
@ -111,7 +149,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) DoMultiStatus(req *http.Request) (*Multistatus, error) {
|
||||
func (c *Client) DoMultiStatus(req *http.Request) (*MultiStatus, error) {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -123,7 +161,7 @@ func (c *Client) DoMultiStatus(req *http.Request) (*Multistatus, error) {
|
||||
}
|
||||
|
||||
// TODO: the response can be quite large, support streaming Response elements
|
||||
var ms Multistatus
|
||||
var ms MultiStatus
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -131,7 +169,7 @@ func (c *Client) DoMultiStatus(req *http.Request) (*Multistatus, error) {
|
||||
return &ms, nil
|
||||
}
|
||||
|
||||
func (c *Client) Propfind(path string, depth Depth, propfind *Propfind) (*Multistatus, error) {
|
||||
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
|
||||
@ -139,17 +177,21 @@ func (c *Client) Propfind(path string, depth Depth, propfind *Propfind) (*Multis
|
||||
|
||||
req.Header.Add("Depth", depth.String())
|
||||
|
||||
return c.DoMultiStatus(req)
|
||||
return c.DoMultiStatus(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// PropfindFlat performs a PROPFIND request with a zero depth.
|
||||
func (c *Client) PropfindFlat(path string, propfind *Propfind) (*Response, error) {
|
||||
ms, err := c.Propfind(path, DepthZero, propfind)
|
||||
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
|
||||
}
|
||||
|
||||
return ms.Get(c.ResolveHref(path).Path)
|
||||
// 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 {
|
||||
@ -170,13 +212,13 @@ func parseCommaSeparatedSet(values []string, upper bool) map[string]bool {
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *Client) Options(path string) (classes map[string]bool, methods map[string]bool, err error) {
|
||||
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)
|
||||
resp, err := c.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -190,3 +232,25 @@ func (c *Client) Options(path string) (classes map[string]bool, methods map[stri
|
||||
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
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -14,14 +15,16 @@ import (
|
||||
const Namespace = "DAV:"
|
||||
|
||||
var (
|
||||
ResourceTypeName = xml.Name{"DAV:", "resourcetype"}
|
||||
DisplayNameName = xml.Name{"DAV:", "displayname"}
|
||||
GetContentLengthName = xml.Name{"DAV:", "getcontentlength"}
|
||||
GetContentTypeName = xml.Name{"DAV:", "getcontenttype"}
|
||||
GetLastModifiedName = xml.Name{"DAV:", "getlastmodified"}
|
||||
GetETagName = xml.Name{"DAV:", "getetag"}
|
||||
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{"DAV:", "current-user-principal"}
|
||||
CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"}
|
||||
|
||||
CurrentUserPrivilegeSetName = xml.Name{Namespace, "current-user-privilege-set"}
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
@ -89,36 +92,22 @@ func (h *Href) UnmarshalText(b []byte) error {
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-14.16
|
||||
type Multistatus struct {
|
||||
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}
|
||||
}
|
||||
|
||||
func (ms *Multistatus) Get(p string) (*Response, error) {
|
||||
// Clean the path to avoid issues with trailing slashes
|
||||
p = path.Clean(p)
|
||||
for i := range ms.Responses {
|
||||
resp := &ms.Responses[i]
|
||||
for _, h := range resp.Hrefs {
|
||||
if path.Clean(h.Path) == p {
|
||||
return resp, resp.Status.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("webdav: missing response for path %q", p)
|
||||
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"`
|
||||
PropStats []PropStat `xml:"propstat,omitempty"`
|
||||
ResponseDescription string `xml:"responsedescription,omitempty"`
|
||||
Status *Status `xml:"status,omitempty"`
|
||||
Error *Error `xml:"error,omitempty"`
|
||||
@ -133,14 +122,57 @@ func NewOKResponse(path string) *Response {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := resp.Status.Err(); err != nil {
|
||||
return "", err
|
||||
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))
|
||||
}
|
||||
if len(resp.Hrefs) != 1 {
|
||||
return "", fmt.Errorf("webdav: malformed response: expected exactly one href element, got %v", len(resp.Hrefs))
|
||||
}
|
||||
return resp.Hrefs[0].Path, nil
|
||||
return path, err
|
||||
}
|
||||
|
||||
func (resp *Response) DecodeProp(values ...interface{}) error {
|
||||
@ -150,40 +182,50 @@ func (resp *Response) DecodeProp(values ...interface{}) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resp.Status.Err(); err != nil {
|
||||
return err
|
||||
if err := resp.Err(); err != nil {
|
||||
return newPropError(name, err)
|
||||
}
|
||||
for _, propstat := range resp.Propstats {
|
||||
for _, propstat := range resp.PropStats {
|
||||
raw := propstat.Prop.Get(name)
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
if err := propstat.Status.Err(); err != nil {
|
||||
return err
|
||||
return newPropError(name, err)
|
||||
}
|
||||
return raw.Decode(v)
|
||||
if err := raw.Decode(v); err != nil {
|
||||
return newPropError(name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return HTTPErrorf(http.StatusNotFound, "missing property %s", name)
|
||||
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]
|
||||
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{
|
||||
resp.PropStats = append(resp.PropStats, PropStat{
|
||||
Status: Status{Code: code},
|
||||
Prop: Prop{Raw: []RawXMLValue{*raw}},
|
||||
})
|
||||
@ -197,7 +239,7 @@ type Location struct {
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-14.22
|
||||
type Propstat struct {
|
||||
type PropStat struct {
|
||||
XMLName xml.Name `xml:"DAV: propstat"`
|
||||
Prop Prop `xml:"prop"`
|
||||
Status Status `xml:"status"`
|
||||
@ -248,7 +290,7 @@ func (p *Prop) Decode(v interface{}) error {
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-14.20
|
||||
type Propfind struct {
|
||||
type PropFind struct {
|
||||
XMLName xml.Name `xml:"DAV: propfind"`
|
||||
Prop *Prop `xml:"prop,omitempty"`
|
||||
AllProp *struct{} `xml:"allprop,omitempty"`
|
||||
@ -264,8 +306,8 @@ func xmlNamesToRaw(names []xml.Name) []RawXMLValue {
|
||||
return l
|
||||
}
|
||||
|
||||
func NewPropNamePropfind(names ...xml.Name) *Propfind {
|
||||
return &Propfind{Prop: &Prop{Raw: xmlNamesToRaw(names)}}
|
||||
func NewPropNamePropFind(names ...xml.Name) *PropFind {
|
||||
return &PropFind{Prop: &Prop{Raw: xmlNamesToRaw(names)}}
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-14.8
|
||||
@ -293,7 +335,7 @@ func (t *ResourceType) Is(name xml.Name) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
var CollectionName = xml.Name{"DAV:", "collection"}
|
||||
var CollectionName = xml.Name{Namespace, "collection"}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-15.4
|
||||
type GetContentLength struct {
|
||||
@ -312,14 +354,14 @@ type Time time.Time
|
||||
func (t *Time) UnmarshalText(b []byte) error {
|
||||
tt, err := http.ParseTime(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
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).Format(time.RFC1123Z)
|
||||
s := time.Time(*t).UTC().Format(http.TimeFormat)
|
||||
return []byte(s), nil
|
||||
}
|
||||
|
||||
@ -378,8 +420,32 @@ type CurrentUserPrincipal struct {
|
||||
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 {
|
||||
type PropertyUpdate struct {
|
||||
XMLName xml.Name `xml:"DAV: propertyupdate"`
|
||||
Remove []Remove `xml:"remove"`
|
||||
Set []Set `xml:"set"`
|
||||
@ -396,3 +462,18 @@ 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"`
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://tools.ietf.org/html/rfc4918#section-9.6.2
|
||||
@ -16,14 +18,20 @@ const exampleDeleteMultistatusStr = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
</d:response>
|
||||
</d:multistatus>`
|
||||
|
||||
func TestMultistatus_Get_error(t *testing.T) {
|
||||
func TestResponse_Err_error(t *testing.T) {
|
||||
r := strings.NewReader(exampleDeleteMultistatusStr)
|
||||
var ms Multistatus
|
||||
var ms MultiStatus
|
||||
if err := xml.NewDecoder(r).Decode(&ms); err != nil {
|
||||
t.Fatalf("Decode() = %v", err)
|
||||
}
|
||||
|
||||
_, err := ms.Get("/container/resource3")
|
||||
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 {
|
||||
@ -32,3 +40,26 @@ func TestMultistatus_Get_error(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,9 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Depth indicates whether a request applies to the resource's members. It's
|
||||
@ -65,3 +67,44 @@ func FormatOverwrite(overwrite bool) string {
|
||||
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
|
||||
}
|
||||
|
@ -2,57 +2,39 @@ package internal
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 {
|
||||
return HTTPErrorFromError(err).Code == http.StatusNotFound
|
||||
}
|
||||
|
||||
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 ServeError(w http.ResponseWriter, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
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 {
|
||||
t, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if t != "application/xml" && t != "text/xml" {
|
||||
if !isContentXML(r.Header) {
|
||||
return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request")
|
||||
}
|
||||
|
||||
@ -62,13 +44,18 @@ func DecodeXMLRequest(r *http.Request, v interface{}) error {
|
||||
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", "text/xml; charset=\"utf-8\"")
|
||||
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 {
|
||||
func ServeMultiStatus(w http.ResponseWriter, ms *MultiStatus) error {
|
||||
// TODO: streaming
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
return ServeXML(w).Encode(ms)
|
||||
@ -77,9 +64,9 @@ func ServeMultistatus(w http.ResponseWriter, ms *Multistatus) error {
|
||||
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(r *http.Request) (*Href, 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)
|
||||
@ -101,17 +88,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
err = h.Backend.HeadGet(w, r)
|
||||
case http.MethodPut:
|
||||
var href *Href
|
||||
href, err = h.Backend.Put(r)
|
||||
if err == nil {
|
||||
// TODO: Last-Modified, ETag, Content-Type if the request has
|
||||
// been copied verbatim
|
||||
if href != nil {
|
||||
w.Header().Set("Location", (*url.URL)(href).String())
|
||||
}
|
||||
// TODO: http.StatusNoContent if the resource already existed
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
err = h.Backend.Put(w, r)
|
||||
case http.MethodDelete:
|
||||
// TODO: send a multistatus in case of partial failure
|
||||
err = h.Backend.Delete(r)
|
||||
@ -135,11 +112,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
code = httpErr.Code
|
||||
}
|
||||
http.Error(w, err.Error(), code)
|
||||
ServeError(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,14 +125,22 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
w.Header().Add("DAV", strings.Join(caps, ", "))
|
||||
w.Header().Add("Allow", strings.Join(allow, ", "))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
|
||||
var propfind Propfind
|
||||
if err := DecodeXMLRequest(r, &propfind); err != nil {
|
||||
return err
|
||||
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
|
||||
@ -171,23 +152,27 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
ms, err := h.Backend.Propfind(r, &propfind, depth)
|
||||
ms, err := h.Backend.PropFind(r, &propfind, depth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ServeMultistatus(w, ms)
|
||||
return ServeMultiStatus(w, ms)
|
||||
}
|
||||
|
||||
type PropfindFunc func(raw *RawXMLValue) (interface{}, error)
|
||||
type PropFindFunc func(raw *RawXMLValue) (interface{}, error)
|
||||
|
||||
func NewPropfindResponse(path string, propfind *Propfind, props map[xml.Name]PropfindFunc) (*Response, error) {
|
||||
resp := NewOKResponse(path)
|
||||
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] = func(*RawXMLValue) (interface{}, error) {
|
||||
return NewResourceType(), nil
|
||||
}
|
||||
props[ResourceTypeName] = PropFindValue(NewResourceType())
|
||||
}
|
||||
|
||||
if propfind.PropName != nil {
|
||||
@ -206,9 +191,8 @@ func NewPropfindResponse(path string, propfind *Propfind, props map[xml.Name]Pro
|
||||
|
||||
code := http.StatusOK
|
||||
if err != nil {
|
||||
// TODO: don't throw away error message here
|
||||
code = HTTPErrorFromError(err).Code
|
||||
val = emptyVal
|
||||
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
|
||||
}
|
||||
|
||||
if err := resp.EncodeProp(code, val); err != nil {
|
||||
@ -229,8 +213,8 @@ func NewPropfindResponse(path string, propfind *Propfind, props map[xml.Name]Pro
|
||||
f, ok := props[xmlName]
|
||||
if ok {
|
||||
if v, err := f(&raw); err != nil {
|
||||
// TODO: don't throw away error message here
|
||||
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
|
||||
@ -251,18 +235,18 @@ func NewPropfindResponse(path string, propfind *Propfind, props map[xml.Name]Pro
|
||||
}
|
||||
|
||||
func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) error {
|
||||
var update Propertyupdate
|
||||
var update PropertyUpdate
|
||||
if err := DecodeXMLRequest(r, &update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := h.Backend.Proppatch(r, &update)
|
||||
resp, err := h.Backend.PropPatch(r, &update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ms := NewMultistatus(*resp)
|
||||
return ServeMultistatus(w, ms)
|
||||
ms := NewMultiStatus(*resp)
|
||||
return ServeMultiStatus(w, ms)
|
||||
}
|
||||
|
||||
func parseDestination(h http.Header) (*Href, error) {
|
||||
|
224
server.go
224
server.go
@ -1,25 +1,27 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
)
|
||||
|
||||
// FileSystem is a WebDAV server backend.
|
||||
type FileSystem interface {
|
||||
Open(name string) (io.ReadCloser, error)
|
||||
Stat(name string) (*FileInfo, error)
|
||||
Readdir(name string, recursive bool) ([]FileInfo, error)
|
||||
Create(name string) (io.WriteCloser, error)
|
||||
RemoveAll(name string) error
|
||||
Mkdir(name string) error
|
||||
Copy(name, dest string, recursive, overwrite bool) (created bool, err error)
|
||||
MoveAll(name, dest string, overwrite bool) (created bool, err error)
|
||||
Open(ctx context.Context, name string) (io.ReadCloser, error)
|
||||
Stat(ctx context.Context, name string) (*FileInfo, error)
|
||||
ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error)
|
||||
Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fileInfo *FileInfo, created bool, err error)
|
||||
RemoveAll(ctx context.Context, name string) error
|
||||
Mkdir(ctx context.Context, name string) error
|
||||
Copy(ctx context.Context, name, dest string, options *CopyOptions) (created bool, err error)
|
||||
Move(ctx context.Context, name, dest string, options *MoveOptions) (created bool, err error)
|
||||
}
|
||||
|
||||
// Handler handles WebDAV HTTP requests. It can be used to create a WebDAV
|
||||
@ -36,17 +38,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
b := backend{h.FileSystem}
|
||||
hh := internal.Handler{&b}
|
||||
hh := internal.Handler{Backend: &b}
|
||||
hh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// NewHTTPError creates a new error that is associated with an HTTP status code
|
||||
// and optionally an error that lead to it. Backends can use this functions to
|
||||
// return errors that convey some semantics (e.g. 404 not found, 403 access
|
||||
// denied, etc.) while also providing an (optional) arbitrary error context
|
||||
// (intended for humans).
|
||||
func NewHTTPError(statusCode int, cause error) error {
|
||||
return &internal.HTTPError{Code: statusCode, Err: cause}
|
||||
}
|
||||
|
||||
type backend struct {
|
||||
FileSystem FileSystem
|
||||
}
|
||||
|
||||
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
|
||||
fi, err := b.FileSystem.Stat(r.URL.Path)
|
||||
if os.IsNotExist(err) {
|
||||
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
|
||||
if internal.IsNotFound(err) {
|
||||
return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
|
||||
} else if err != nil {
|
||||
return nil, nil, err
|
||||
@ -68,17 +79,15 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e
|
||||
}
|
||||
|
||||
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
||||
fi, err := b.FileSystem.Stat(r.URL.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
|
||||
} else if err != nil {
|
||||
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.IsDir {
|
||||
return &internal.HTTPError{Code: http.StatusMethodNotAllowed}
|
||||
}
|
||||
|
||||
f, err := b.FileSystem.Open(r.URL.Path)
|
||||
f, err := b.FileSystem.Open(r.Context(), r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -106,33 +115,31 @@ func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth internal.Depth) (*internal.Multistatus, error) {
|
||||
func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) {
|
||||
// TODO: use partial error Response on error
|
||||
|
||||
fi, err := b.FileSystem.Stat(r.URL.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, &internal.HTTPError{Code: http.StatusNotFound, Err: err}
|
||||
} else if err != nil {
|
||||
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resps []internal.Response
|
||||
if depth != internal.DepthZero && fi.IsDir {
|
||||
children, err := b.FileSystem.Readdir(r.URL.Path, depth == internal.DepthInfinity)
|
||||
children, err := b.FileSystem.ReadDir(r.Context(), r.URL.Path, depth == internal.DepthInfinity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resps = make([]internal.Response, len(children))
|
||||
for i, child := range children {
|
||||
resp, err := b.propfindFile(propfind, &child)
|
||||
resp, err := b.propFindFile(propfind, &child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resps[i] = *resp
|
||||
}
|
||||
} else {
|
||||
resp, err := b.propfindFile(propfind, fi)
|
||||
resp, err := b.propFindFile(propfind, fi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -140,11 +147,11 @@ func (b *backend) Propfind(r *http.Request, propfind *internal.Propfind, depth i
|
||||
resps = []internal.Response{*resp}
|
||||
}
|
||||
|
||||
return internal.NewMultistatus(resps...), nil
|
||||
return internal.NewMultiStatus(resps...), nil
|
||||
}
|
||||
|
||||
func (b *backend) propfindFile(propfind *internal.Propfind, fi *FileInfo) (*internal.Response, error) {
|
||||
props := make(map[xml.Name]internal.PropfindFunc)
|
||||
func (b *backend) propFindFile(propfind *internal.PropFind, fi *FileInfo) (*internal.Response, error) {
|
||||
props := make(map[xml.Name]internal.PropFindFunc)
|
||||
|
||||
props[internal.ResourceTypeName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
var types []xml.Name
|
||||
@ -155,72 +162,90 @@ func (b *backend) propfindFile(propfind *internal.Propfind, fi *FileInfo) (*inte
|
||||
}
|
||||
|
||||
if !fi.IsDir {
|
||||
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetContentLength{Length: fi.Size}, nil
|
||||
}
|
||||
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
|
||||
Length: fi.Size,
|
||||
})
|
||||
|
||||
if !fi.ModTime.IsZero() {
|
||||
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetLastModified{LastModified: internal.Time(fi.ModTime)}, nil
|
||||
}
|
||||
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
|
||||
LastModified: internal.Time(fi.ModTime),
|
||||
})
|
||||
}
|
||||
|
||||
if fi.MIMEType != "" {
|
||||
props[internal.GetContentTypeName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetContentType{Type: fi.MIMEType}, nil
|
||||
}
|
||||
props[internal.GetContentTypeName] = internal.PropFindValue(&internal.GetContentType{
|
||||
Type: fi.MIMEType,
|
||||
})
|
||||
}
|
||||
|
||||
if fi.ETag != "" {
|
||||
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.GetETag{ETag: internal.ETag(fi.ETag)}, nil
|
||||
}
|
||||
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
|
||||
ETag: internal.ETag(fi.ETag),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return internal.NewPropfindResponse(fi.Path, propfind, props)
|
||||
return internal.NewPropFindResponse(fi.Path, propfind, props)
|
||||
}
|
||||
|
||||
func (b *backend) Proppatch(r *http.Request, update *internal.Propertyupdate) (*internal.Response, error) {
|
||||
func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*internal.Response, error) {
|
||||
// TODO: return a failed Response instead
|
||||
return nil, internal.HTTPErrorf(http.StatusForbidden, "webdav: PROPPATCH is unsupported")
|
||||
}
|
||||
|
||||
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
|
||||
wc, err := b.FileSystem.Create(r.URL.Path)
|
||||
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
|
||||
ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match"))
|
||||
ifMatch := ConditionalMatch(r.Header.Get("If-Match"))
|
||||
|
||||
opts := CreateOptions{
|
||||
IfNoneMatch: ifNoneMatch,
|
||||
IfMatch: ifMatch,
|
||||
}
|
||||
fi, created, err := b.FileSystem.Create(r.Context(), r.URL.Path, r.Body, &opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer wc.Close()
|
||||
|
||||
if _, err := io.Copy(wc, r.Body); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
return nil, wc.Close()
|
||||
if fi.MIMEType != "" {
|
||||
w.Header().Set("Content-Type", fi.MIMEType)
|
||||
}
|
||||
if !fi.ModTime.IsZero() {
|
||||
w.Header().Set("Last-Modified", fi.ModTime.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if fi.ETag != "" {
|
||||
w.Header().Set("ETag", internal.ETag(fi.ETag).String())
|
||||
}
|
||||
|
||||
if created {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backend) Delete(r *http.Request) error {
|
||||
err := b.FileSystem.RemoveAll(r.URL.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
|
||||
}
|
||||
return err
|
||||
return b.FileSystem.RemoveAll(r.Context(), r.URL.Path)
|
||||
}
|
||||
|
||||
func (b *backend) Mkcol(r *http.Request) error {
|
||||
if r.Header.Get("Content-Type") != "" {
|
||||
return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request")
|
||||
}
|
||||
err := b.FileSystem.Mkdir(r.URL.Path)
|
||||
if os.IsNotExist(err) {
|
||||
err := b.FileSystem.Mkdir(r.Context(), r.URL.Path)
|
||||
if internal.IsNotFound(err) {
|
||||
return &internal.HTTPError{Code: http.StatusConflict, Err: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
|
||||
created, err = b.FileSystem.Copy(r.URL.Path, dest.Path, recursive, overwrite)
|
||||
options := CopyOptions{
|
||||
NoRecursive: !recursive,
|
||||
NoOverwrite: !overwrite,
|
||||
}
|
||||
created, err = b.FileSystem.Copy(r.Context(), r.URL.Path, dest.Path, &options)
|
||||
if os.IsExist(err) {
|
||||
return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
|
||||
}
|
||||
@ -228,9 +253,90 @@ func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrit
|
||||
}
|
||||
|
||||
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
|
||||
created, err = b.FileSystem.MoveAll(r.URL.Path, dest.Path, overwrite)
|
||||
options := MoveOptions{
|
||||
NoOverwrite: !overwrite,
|
||||
}
|
||||
created, err = b.FileSystem.Move(r.Context(), r.URL.Path, dest.Path, &options)
|
||||
if os.IsExist(err) {
|
||||
return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
// BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a
|
||||
// CardDAV addressbook-home-set. It should only be created via
|
||||
// caldav.NewCalendarHomeSet or carddav.NewAddressBookHomeSet. Only to
|
||||
// be used server-side, for listing a user's home sets as determined by the
|
||||
// (external) backend.
|
||||
type BackendSuppliedHomeSet interface {
|
||||
GetXMLName() xml.Name
|
||||
}
|
||||
|
||||
// UserPrincipalBackend can determine the current user's principal URL for a
|
||||
// given request context.
|
||||
type UserPrincipalBackend interface {
|
||||
CurrentUserPrincipal(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// Capability indicates the features that a server supports.
|
||||
type Capability string
|
||||
|
||||
// ServePrincipalOptions holds options for ServePrincipal.
|
||||
type ServePrincipalOptions struct {
|
||||
CurrentUserPrincipalPath string
|
||||
HomeSets []BackendSuppliedHomeSet
|
||||
Capabilities []Capability
|
||||
}
|
||||
|
||||
// ServePrincipal replies to requests for a principal URL.
|
||||
func ServePrincipal(w http.ResponseWriter, r *http.Request, options *ServePrincipalOptions) {
|
||||
switch r.Method {
|
||||
case http.MethodOptions:
|
||||
caps := []string{"1", "3"}
|
||||
for _, c := range options.Capabilities {
|
||||
caps = append(caps, string(c))
|
||||
}
|
||||
allow := []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}
|
||||
w.Header().Add("DAV", strings.Join(caps, ", "))
|
||||
w.Header().Add("Allow", strings.Join(allow, ", "))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "PROPFIND":
|
||||
if err := servePrincipalPropfind(w, r, options); err != nil {
|
||||
internal.ServeError(w, err)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func servePrincipalPropfind(w http.ResponseWriter, r *http.Request, options *ServePrincipalOptions) error {
|
||||
var propfind internal.PropFind
|
||||
if err := internal.DecodeXMLRequest(r, &propfind); err != nil {
|
||||
return err
|
||||
}
|
||||
props := map[xml.Name]internal.PropFindFunc{
|
||||
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return internal.NewResourceType(principalName), nil
|
||||
},
|
||||
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: options.CurrentUserPrincipalPath}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: handle Depth and more properties
|
||||
|
||||
for _, homeSet := range options.HomeSets {
|
||||
hs := homeSet // capture variable for closure
|
||||
props[homeSet.GetXMLName()] = func(*internal.RawXMLValue) (interface{}, error) {
|
||||
return hs, nil
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := internal.NewPropFindResponse(r.URL.Path, &propfind, props)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ms := internal.NewMultiStatus(*resp)
|
||||
return internal.ServeMultiStatus(w, ms)
|
||||
}
|
||||
|
46
webdav.go
46
webdav.go
@ -5,8 +5,11 @@ package webdav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-webdav/internal"
|
||||
)
|
||||
|
||||
// FileInfo holds information about a WebDAV file.
|
||||
type FileInfo struct {
|
||||
Path string
|
||||
Size int64
|
||||
@ -15,3 +18,46 @@ type FileInfo struct {
|
||||
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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user