Compare commits

..

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

24 changed files with 460 additions and 1121 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,6 +1,7 @@
# go-webdav # go-webdav
[![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-webdav.svg)](https://pkg.go.dev/github.com/emersion/go-webdav) [![godocs.io](https://godocs.io/github.com/emersion/go-webdav?status.svg)](https://godocs.io/github.com/emersion/go-webdav)
[![builds.sr.ht status](https://builds.sr.ht/~emersion/go-webdav/commits/master.svg)](https://builds.sr.ht/~emersion/go-webdav/commits/master?)
A Go library for [WebDAV], [CalDAV] and [CardDAV]. A Go library for [WebDAV], [CalDAV] and [CardDAV].

View File

@ -2,7 +2,6 @@ package caldav
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"mime" "mime"
"net/http" "net/http"
@ -16,12 +15,6 @@ import (
"github.com/emersion/go-webdav/internal" "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. // Client provides access to a remote CardDAV server.
type Client struct { type Client struct {
*webdav.Client *webdav.Client
@ -41,9 +34,9 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
return &Client{wc, ic}, nil return &Client{wc, ic}, nil
} }
func (c *Client) FindCalendarHomeSet(ctx context.Context, principal string) (string, error) { func (c *Client) FindCalendarHomeSet(principal string) (string, error) {
propfind := internal.NewPropNamePropFind(calendarHomeSetName) propfind := internal.NewPropNamePropFind(calendarHomeSetName)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind) resp, err := c.ic.PropFindFlat(principal, propfind)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -56,7 +49,7 @@ func (c *Client) FindCalendarHomeSet(ctx context.Context, principal string) (str
return prop.Href.Path, nil return prop.Href.Path, nil
} }
func (c *Client) FindCalendars(ctx context.Context, calendarHomeSet string) ([]Calendar, error) { func (c *Client) FindCalendars(calendarHomeSet string) ([]Calendar, error) {
propfind := internal.NewPropNamePropFind( propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName, internal.ResourceTypeName,
internal.DisplayNameName, internal.DisplayNameName,
@ -64,7 +57,7 @@ func (c *Client) FindCalendars(ctx context.Context, calendarHomeSet string) ([]C
maxResourceSizeName, maxResourceSizeName,
supportedCalendarComponentSetName, supportedCalendarComponentSetName,
) )
ms, err := c.ic.PropFind(ctx, calendarHomeSet, internal.DepthOne, propfind) ms, err := c.ic.PropFind(calendarHomeSet, internal.DepthOne, propfind)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -221,7 +214,7 @@ func decodeCalendarObjectList(ms *internal.MultiStatus) ([]CalendarObject, error
return addrs, nil return addrs, nil
} }
func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *CalendarQuery) ([]CalendarObject, error) { func (c *Client) QueryCalendar(calendar string, query *CalendarQuery) ([]CalendarObject, error) {
propReq, err := encodeCalendarReq(&query.CompRequest) propReq, err := encodeCalendarReq(&query.CompRequest)
if err != nil { if err != nil {
return nil, err return nil, err
@ -235,7 +228,7 @@ func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *Cale
} }
req.Header.Add("Depth", "1") req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx)) ms, err := c.ic.DoMultiStatus(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -243,7 +236,7 @@ func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *Cale
return decodeCalendarObjectList(ms) return decodeCalendarObjectList(ms)
} }
func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *CalendarMultiGet) ([]CalendarObject, error) { func (c *Client) MultiGetCalendar(path string, multiGet *CalendarMultiGet) ([]CalendarObject, error) {
propReq, err := encodeCalendarReq(&multiGet.CompRequest) propReq, err := encodeCalendarReq(&multiGet.CompRequest)
if err != nil { if err != nil {
return nil, err return nil, err
@ -251,7 +244,7 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
calendarMultiget := calendarMultiget{Prop: propReq} calendarMultiget := calendarMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 { if multiGet == nil || len(multiGet.Paths) == 0 {
href := internal.Href{Path: path} href := internal.Href{Path: path}
calendarMultiget.Hrefs = []internal.Href{href} calendarMultiget.Hrefs = []internal.Href{href}
} else { } else {
@ -267,7 +260,7 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
} }
req.Header.Add("Depth", "1") req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx)) ms, err := c.ic.DoMultiStatus(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -275,29 +268,29 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
return decodeCalendarObjectList(ms) return decodeCalendarObjectList(ms)
} }
func populateCalendarObject(co *CalendarObject, h http.Header) error { func populateCalendarObject(co *CalendarObject, resp *http.Response) error {
if loc := h.Get("Location"); loc != "" { if loc := resp.Header.Get("Location"); loc != "" {
u, err := url.Parse(loc) u, err := url.Parse(loc)
if err != nil { if err != nil {
return err return err
} }
co.Path = u.Path co.Path = u.Path
} }
if etag := h.Get("ETag"); etag != "" { if etag := resp.Header.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag) etag, err := strconv.Unquote(etag)
if err != nil { if err != nil {
return err return err
} }
co.ETag = etag co.ETag = etag
} }
if contentLength := h.Get("Content-Length"); contentLength != "" { if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
n, err := strconv.ParseInt(contentLength, 10, 64) n, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil { if err != nil {
return err return err
} }
co.ContentLength = n co.ContentLength = n
} }
if lastModified := h.Get("Last-Modified"); lastModified != "" { if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified) t, err := http.ParseTime(lastModified)
if err != nil { if err != nil {
return err return err
@ -308,14 +301,14 @@ func populateCalendarObject(co *CalendarObject, h http.Header) error {
return nil return nil
} }
func (c *Client) GetCalendarObject(ctx context.Context, path string) (*CalendarObject, error) { func (c *Client) GetCalendarObject(path string) (*CalendarObject, error) {
req, err := c.ic.NewRequest(http.MethodGet, path, nil) req, err := c.ic.NewRequest(http.MethodGet, path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", ical.MIMEType) req.Header.Set("Accept", ical.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -338,13 +331,13 @@ func (c *Client) GetCalendarObject(ctx context.Context, path string) (*CalendarO
Path: resp.Request.URL.Path, Path: resp.Request.URL.Path,
Data: cal, Data: cal,
} }
if err := populateCalendarObject(co, resp.Header); err != nil { if err := populateCalendarObject(co, resp); err != nil {
return nil, err return nil, err
} }
return co, nil return co, nil
} }
func (c *Client) PutCalendarObject(ctx context.Context, path string, cal *ical.Calendar) (*CalendarObject, error) { func (c *Client) PutCalendarObject(path string, cal *ical.Calendar) (*CalendarObject, error) {
// TODO: add support for If-None-Match and If-Match // TODO: add support for If-None-Match and If-Match
// TODO: some servers want a Content-Length header, so we can't stream the // TODO: some servers want a Content-Length header, so we can't stream the
@ -362,14 +355,14 @@ func (c *Client) PutCalendarObject(ctx context.Context, path string, cal *ical.C
} }
req.Header.Set("Content-Type", ical.MIMEType) req.Header.Set("Content-Type", ical.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp.Body.Close() resp.Body.Close()
co := &CalendarObject{Path: path} co := &CalendarObject{Path: path}
if err := populateCalendarObject(co, resp.Header); err != nil { if err := populateCalendarObject(co, resp); err != nil {
return nil, err return nil, err
} }
return co, nil return co, nil

View File

@ -228,10 +228,3 @@ func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return d.DecodeElement(v, &start) return d.DecodeElement(v, &start)
} }
type mkcolReq struct {
XMLName xml.Name `xml:"DAV: mkcol"`
ResourceType internal.ResourceType `xml:"set>prop>resourcetype"`
DisplayName string `xml:"set>prop>displayname"`
// TODO this could theoretically contain all addressbook properties?
}

View File

@ -138,6 +138,7 @@ func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error
return len(rset.Between(start, end, true)) > 0, nil return len(rset.Between(start, end, true)) > 0, nil
} }
// TODO handle "infinity" values in query
// TODO handle more than just events // TODO handle more than just events
if comp.Name != ical.CompEvent { if comp.Name != ical.CompEvent {
return false, nil return false, nil
@ -154,15 +155,15 @@ func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error
} }
// Event starts in time range // Event starts in time range
if eventStart.After(start) && (end.IsZero() || eventStart.Before(end)) { if eventStart.After(start) && eventStart.Before(end) {
return true, nil return true, nil
} }
// Event ends in time range // Event ends in time range
if eventEnd.After(start) && (end.IsZero() || eventEnd.Before(end)) { if eventEnd.After(start) && eventEnd.Before(end) {
return true, nil return true, nil
} }
// Event covers entire time range plus some // Event covers entire time range plus some
if eventStart.Before(start) && (!end.IsZero() && eventEnd.After(end)) { if eventStart.Before(start) && eventEnd.After(end) {
return true, nil return true, nil
} }
return false, nil return false, nil
@ -171,11 +172,13 @@ func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error
func matchPropTimeRange(start, end time.Time, field *ical.Prop) (bool, error) { func matchPropTimeRange(start, end time.Time, field *ical.Prop) (bool, error) {
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9 // See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
// TODO handle "infinity" values in query
ptime, err := field.DateTime(start.Location()) ptime, err := field.DateTime(start.Location())
if err != nil { if err != nil {
return false, err return false, err
} }
if ptime.After(start) && (end.IsZero() || ptime.Before(end)) { if ptime.After(start) && ptime.Before(end) {
return true, nil return true, nil
} }
return false, nil return false, nil

View File

@ -209,23 +209,6 @@ END:VCALENDAR`)
addrs: []CalendarObject{event1, event2, event3, todo1}, addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2, event3}, 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 // https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
name: "events by UID", name: "events by UID",

View File

@ -17,6 +17,8 @@ import (
"github.com/emersion/go-webdav/internal" "github.com/emersion/go-webdav/internal"
) )
// TODO: add support for multiple calendars
// TODO if nothing more Caldav-specific needs to be added this should be merged with carddav.PutAddressObjectOptions // TODO if nothing more Caldav-specific needs to be added this should be merged with carddav.PutAddressObjectOptions
type PutCalendarObjectOptions struct { type PutCalendarObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite // IfNoneMatch indicates that the client does not want to overwrite
@ -30,15 +32,11 @@ type PutCalendarObjectOptions struct {
// Backend is a CalDAV server backend. // Backend is a CalDAV server backend.
type Backend interface { type Backend interface {
CalendarHomeSetPath(ctx context.Context) (string, error) CalendarHomeSetPath(ctx context.Context) (string, error)
Calendar(ctx context.Context) (*Calendar, 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) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) ListCalendarObjects(ctx context.Context, req *CalendarCompRequest) ([]CalendarObject, error)
QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) QueryCalendarObjects(ctx context.Context, query *CalendarQuery) ([]CalendarObject, error)
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (loc string, err error)
DeleteCalendarObject(ctx context.Context, path string) error DeleteCalendarObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend webdav.UserPrincipalBackend
@ -78,7 +76,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Backend: h.Backend, Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"), Prefix: strings.TrimSuffix(h.Prefix, "/"),
} }
hh := internal.Handler{Backend: &b} hh := internal.Handler{&b}
hh.ServeHTTP(w, r) hh.ServeHTTP(w, r)
} }
@ -216,7 +214,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *cal
} }
q.CompFilter = *cf q.CompFilter = *cf
cos, err := h.Backend.QueryCalendarObjects(r.Context(), r.URL.Path, &q) cos, err := h.Backend.QueryCalendarObjects(r.Context(), &q)
if err != nil { if err != nil {
return err return err
} }
@ -373,12 +371,6 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
var resps []internal.Response var resps []internal.Response
switch resType { switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal: case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil { if err != nil {
@ -426,21 +418,24 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
} }
} }
case resourceTypeCalendar: case resourceTypeCalendar:
ab, err := b.Backend.GetCalendar(r.Context(), r.URL.Path) // TODO for multiple calendars, look through all of them
ab, err := b.Backend.Calendar(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := b.propFindCalendar(r.Context(), propfind, ab) if r.URL.Path == ab.Path {
if err != nil { resp, err := b.propFindCalendar(r.Context(), propfind, ab)
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resps = append(resps, resps_...) 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: case resourceTypeCalendarObject:
ao, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq) ao, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
@ -458,21 +453,6 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
return internal.NewMultiStatus(resps...), nil 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) { func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx) principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil { if err != nil {
@ -484,13 +464,15 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.
} }
props := map[xml.Name]internal.PropFindFunc{ props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{ internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
Href: internal.Href{Path: principalPath}, return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
}), },
calendarHomeSetName: internal.PropFindValue(&calendarHomeSet{ calendarHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
Href: internal.Href{Path: homeSetPath}, return &calendarHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
}), },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
} }
return internal.NewPropFindResponse(principalPath, propfind, props) return internal.NewPropFindResponse(principalPath, propfind, props)
} }
@ -507,10 +489,12 @@ func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFi
// TODO anything else to return here? // TODO anything else to return here?
props := map[xml.Name]internal.PropFindFunc{ props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{ internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
Href: internal.Href{Path: principalPath}, return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
}), },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
} }
return internal.NewPropFindResponse(homeSetPath, propfind, props) return internal.NewPropFindResponse(homeSetPath, propfind, props)
} }
@ -524,47 +508,41 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
} }
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
}, },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, calendarName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
calendarDescriptionName: internal.PropFindValue(&calendarDescription{ return internal.NewResourceType(internal.CollectionName, calendarName), nil
Description: cal.Description, },
}), internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
supportedCalendarDataName: internal.PropFindValue(&supportedCalendarData{ return &internal.DisplayName{Name: cal.Name}, nil
Types: []calendarDataType{ },
{ContentType: ical.MIMEType, Version: "2.0"}, calendarDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
}, return &calendarDescription{Description: cal.Description}, nil
}), },
supportedCalendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
return &supportedCalendarData{
Types: []calendarDataType{
{ContentType: ical.MIMEType, Version: "2.0"},
},
}, nil
},
supportedCalendarComponentSetName: func(*internal.RawXMLValue) (interface{}, error) { 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{ return &supportedCalendarComponentSet{
Comp: components, Comp: []comp{
{Name: ical.CompEvent},
},
}, nil }, nil
}, },
} }
if cal.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: cal.Name,
})
}
if cal.Description != "" { if cal.Description != "" {
props[calendarDescriptionName] = internal.PropFindValue(&calendarDescription{ props[calendarDescriptionName] = func(*internal.RawXMLValue) (interface{}, error) {
Description: cal.Description, return &calendarDescription{Description: cal.Description}, nil
}) }
} }
if cal.MaxResourceSize > 0 { if cal.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{ props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
Size: cal.MaxResourceSize, return &maxResourceSize{Size: cal.MaxResourceSize}, nil
}) }
}
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 // 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
@ -573,20 +551,22 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
} }
func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) { func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) {
abs, err := b.Backend.ListCalendars(ctx) // TODO iterate over all calendars once having multiple is supported
ab, err := b.Backend.Calendar(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
abs := []*Calendar{ab}
var resps []internal.Response var resps []internal.Response
for _, ab := range abs { for _, ab := range abs {
resp, err := b.propFindCalendar(ctx, propfind, &ab) resp, err := b.propFindCalendar(ctx, propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resps = append(resps, *resp) resps = append(resps, *resp)
if recurse { if recurse {
resps_, err := b.propFindAllCalendarObjects(ctx, propfind, &ab) resps_, err := b.propFindAllCalendarObjects(ctx, propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -605,9 +585,9 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
} }
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
}, },
internal.GetContentTypeName: internal.PropFindValue(&internal.GetContentType{ internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
Type: ical.MIMEType, return &internal.GetContentType{Type: ical.MIMEType}, nil
}), },
// TODO: calendar-data can only be used in REPORT requests // TODO: calendar-data can only be used in REPORT requests
calendarDataName: func(*internal.RawXMLValue) (interface{}, error) { calendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer var buf bytes.Buffer
@ -620,20 +600,20 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
} }
if co.ContentLength > 0 { if co.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{ props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
Length: co.ContentLength, return &internal.GetContentLength{Length: co.ContentLength}, nil
}) }
} }
if !co.ModTime.IsZero() { if !co.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{ props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
LastModified: internal.Time(co.ModTime), return &internal.GetLastModified{LastModified: internal.Time(co.ModTime)}, nil
}) }
} }
if co.ETag != "" { if co.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{ props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
ETag: internal.ETag(co.ETag), return &internal.GetETag{ETag: internal.ETag(co.ETag)}, nil
}) }
} }
return internal.NewPropFindResponse(co.Path, propfind, props) return internal.NewPropFindResponse(co.Path, propfind, props)
@ -641,7 +621,7 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, cal *Calendar) ([]internal.Response, error) { func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, cal *Calendar) ([]internal.Response, error) {
var dataReq CalendarCompRequest var dataReq CalendarCompRequest
aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq) aos, err := b.Backend.ListCalendarObjects(ctx, &dataReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -658,10 +638,10 @@ func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *inte
} }
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) {
return nil, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: PropPatch not implemented") panic("TODO")
} }
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error { func (b *backend) Put(r *http.Request) (*internal.Href, error) {
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match")) ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match")) ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
@ -672,39 +652,26 @@ func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil { if err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err) return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err)
} }
if t != ical.MIMEType { if t != ical.MIMEType {
// TODO: send CALDAV:supported-calendar-data error // TODO: send CALDAV:supported-calendar-data error
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t) return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t)
} }
// TODO: check CALDAV:max-resource-size precondition // TODO: check CALDAV:max-resource-size precondition
cal, err := ical.NewDecoder(r.Body).Decode() cal, err := ical.NewDecoder(r.Body).Decode()
if err != nil { if err != nil {
// TODO: send CALDAV:valid-calendar-data error // TODO: send CALDAV:valid-calendar-data error
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err) return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
} }
co, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts) loc, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts)
if err != nil { if err != nil {
return err return nil, err
} }
if co.ETag != "" { return &internal.Href{Path: loc}, nil
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 { func (b *backend) Delete(r *http.Request) error {
@ -712,36 +679,15 @@ func (b *backend) Delete(r *http.Request) error {
} }
func (b *backend) Mkcol(r *http.Request) error { func (b *backend) Mkcol(r *http.Request) error {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar { panic("TODO")
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) { 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") panic("TODO")
} }
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) { 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") panic("TODO")
} }
// https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1 // https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
@ -762,7 +708,7 @@ const (
) )
func NewPreconditionError(err PreconditionType) error { func NewPreconditionError(err PreconditionType) error {
name := xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: string(err)} name := xml.Name{"urn:ietf:params:xml:ns:caldav", string(err)}
elem := internal.NewRawXMLElement(name, nil, nil) elem := internal.NewRawXMLElement(name, nil, nil)
return &internal.HTTPError{ return &internal.HTTPError{
Code: 409, Code: 409,

View File

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

View File

@ -12,9 +12,7 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
) )
type testBackend struct { type testBackend struct{}
addressBooks []AddressBook
}
type contextKey string type contextKey string
@ -39,46 +37,22 @@ func (*testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return r, nil return r, nil
} }
func (*testBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { func (*testBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) {
r := ctx.Value(homeSetPathKey).(string) r := ctx.Value(homeSetPathKey).(string)
return r, nil return r, nil
} }
func (*testBackend) ListAddressBooks(ctx context.Context) ([]AddressBook, error) { func (*testBackend) AddressBook(ctx context.Context) (*AddressBook, error) {
p := ctx.Value(addressBookPathKey).(string) p := ctx.Value(addressBookPathKey).(string)
return []AddressBook{ return &AddressBook{
AddressBook{ Path: p,
Path: p, Name: "My contacts",
Name: "My contacts", Description: "Default address book",
Description: "Default address book", MaxResourceSize: 1024,
MaxResourceSize: 1024, SupportedAddressData: nil,
SupportedAddressData: nil,
},
}, 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) { func (*testBackend) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error) {
if path == alicePath { if path == alicePath {
card, err := vcard.NewDecoder(strings.NewReader(aliceData)).Decode() card, err := vcard.NewDecoder(strings.NewReader(aliceData)).Decode()
@ -94,11 +68,7 @@ func (*testBackend) GetAddressObject(ctx context.Context, path string, req *Addr
} }
} }
func (b *testBackend) ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error) { func (b *testBackend) ListAddressObjects(ctx context.Context, 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) alice, err := b.GetAddressObject(ctx, alicePath, req)
if err != nil { if err != nil {
return nil, err return nil, err
@ -107,11 +77,11 @@ func (b *testBackend) ListAddressObjects(ctx context.Context, path string, req *
return []AddressObject{*alice}, nil return []AddressObject{*alice}, nil
} }
func (*testBackend) QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error) { func (*testBackend) QueryAddressObjects(ctx context.Context, query *AddressBookQuery) ([]AddressObject, error) {
panic("TODO: implement") panic("TODO: implement")
} }
func (*testBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, error) { func (*testBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (loc string, err error) {
panic("TODO: implement") panic("TODO: implement")
} }
@ -143,7 +113,6 @@ func TestAddressBookDiscovery(t *testing.T) {
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
h := Handler{&testBackend{}, tc.prefix} h := Handler{&testBackend{}, tc.prefix}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -166,21 +135,21 @@ func TestAddressBookDiscovery(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error creating client: %s", err) t.Fatalf("error creating client: %s", err)
} }
cup, err := client.FindCurrentUserPrincipal(ctx) cup, err := client.FindCurrentUserPrincipal()
if err != nil { if err != nil {
t.Fatalf("error finding user principal url: %s", err) t.Fatalf("error finding user principal url: %s", err)
} }
if cup != tc.currentUserPrincipal { if cup != tc.currentUserPrincipal {
t.Fatalf("Found current user principal URL '%s', expected '%s'", cup, tc.currentUserPrincipal) t.Fatalf("Found current user principal URL '%s', expected '%s'", cup, tc.currentUserPrincipal)
} }
hsp, err := client.FindAddressBookHomeSet(ctx, cup) hsp, err := client.FindAddressBookHomeSet(cup)
if err != nil { if err != nil {
t.Fatalf("error finding home set path: %s", err) t.Fatalf("error finding home set path: %s", err)
} }
if hsp != tc.homeSetPath { if hsp != tc.homeSetPath {
t.Fatalf("Found home set path '%s', expected '%s'", hsp, tc.homeSetPath) t.Fatalf("Found home set path '%s', expected '%s'", hsp, tc.homeSetPath)
} }
abs, err := client.FindAddressBooks(ctx, hsp) abs, err := client.FindAddressBooks(hsp)
if err != nil { if err != nil {
t.Fatalf("error finding address books: %s", err) t.Fatalf("error finding address books: %s", err)
} }
@ -193,50 +162,3 @@ func TestAddressBookDiscovery(t *testing.T) {
}) })
} }
} }
var mkcolRequestBody = `
<?xml version="1.0" encoding="utf-8" ?>
<D:mkcol xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:set>
<D:prop>
<D:resourcetype>
<D:collection/>
<C:addressbook/>
</D:resourcetype>
<D:displayname>Lisa's Contacts</D:displayname>
<C:addressbook-description xml:lang="en"
>My primary address book.</C:addressbook-description>
</D:prop>
</D:set>
</D:mkcol>`
func TestCreateAddressbookMinimalBody(t *testing.T) {
tb := testBackend{
addressBooks: nil,
}
b := backend{
Backend: &tb,
Prefix: "/dav",
}
req := httptest.NewRequest("MKCOL", "/dav/addressbooks/user0/test-addressbook", strings.NewReader(mkcolRequestBody))
req.Header.Set("Content-Type", "application/xml")
err := b.Mkcol(req)
if err != nil {
t.Fatalf("Unexpcted error in Mkcol: %s", err)
}
if len(tb.addressBooks) != 1 {
t.Fatalf("Found %d address books, expected 1", len(tb.addressBooks))
}
c := tb.addressBooks[0]
if c.Name != "Lisa's Contacts" {
t.Fatalf("Address book name is '%s', expected 'Lisa's Contacts'", c.Name)
}
if c.Path != "/dav/addressbooks/user0/test-addressbook" {
t.Fatalf("Address book path is '%s', expected '/dav/addressbooks/user0/test-addressbook'", c.Path)
}
if c.Description != "My primary address book." {
t.Fatalf("Address book sdscription is '%s', expected 'My primary address book.'", c.Description)
}
}

View File

@ -2,9 +2,9 @@ package carddav
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"mime" "mime"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -16,10 +16,38 @@ import (
"github.com/emersion/go-webdav/internal" "github.com/emersion/go-webdav/internal"
) )
// DiscoverContextURL performs a DNS-based CardDAV service discovery as // Discover performs a DNS-based CardDAV service discovery as described in
// described in RFC 6352 section 11. It returns the URL to the CardDAV server. // RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) { func Discover(domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "carddavs", domain) // 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")
}
// 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/carddav"
return u.String(), nil
} }
// Client provides access to a remote CardDAV server. // Client provides access to a remote CardDAV server.
@ -41,8 +69,8 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
return &Client{wc, ic}, nil return &Client{wc, ic}, nil
} }
func (c *Client) HasSupport(ctx context.Context) error { func (c *Client) HasSupport() error {
classes, _, err := c.ic.Options(ctx, "") classes, _, err := c.ic.Options("")
if err != nil { if err != nil {
return err return err
} }
@ -53,9 +81,9 @@ func (c *Client) HasSupport(ctx context.Context) error {
return nil return nil
} }
func (c *Client) FindAddressBookHomeSet(ctx context.Context, principal string) (string, error) { func (c *Client) FindAddressBookHomeSet(principal string) (string, error) {
propfind := internal.NewPropNamePropFind(addressBookHomeSetName) propfind := internal.NewPropNamePropFind(addressBookHomeSetName)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind) resp, err := c.ic.PropFindFlat(principal, propfind)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -76,7 +104,7 @@ func decodeSupportedAddressData(supported *supportedAddressData) []AddressDataTy
return l return l
} }
func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string) ([]AddressBook, error) { func (c *Client) FindAddressBooks(addressBookHomeSet string) ([]AddressBook, error) {
propfind := internal.NewPropNamePropFind( propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName, internal.ResourceTypeName,
internal.DisplayNameName, internal.DisplayNameName,
@ -84,7 +112,7 @@ func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string
maxResourceSizeName, maxResourceSizeName,
supportedAddressDataName, supportedAddressDataName,
) )
ms, err := c.ic.PropFind(ctx, addressBookHomeSet, internal.DepthOne, propfind) ms, err := c.ic.PropFind(addressBookHomeSet, internal.DepthOne, propfind)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -243,7 +271,7 @@ func decodeAddressList(ms *internal.MultiStatus) ([]AddressObject, error) {
return addrs, nil return addrs, nil
} }
func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query *AddressBookQuery) ([]AddressObject, error) { func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&query.DataRequest) propReq, err := encodeAddressPropReq(&query.DataRequest)
if err != nil { if err != nil {
return nil, err return nil, err
@ -269,7 +297,7 @@ func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query
req.Header.Add("Depth", "1") req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx)) ms, err := c.ic.DoMultiStatus(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -277,7 +305,7 @@ func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query
return decodeAddressList(ms) return decodeAddressList(ms)
} }
func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) { func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&multiGet.DataRequest) propReq, err := encodeAddressPropReq(&multiGet.DataRequest)
if err != nil { if err != nil {
return nil, err return nil, err
@ -285,7 +313,7 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
addressbookMultiget := addressbookMultiget{Prop: propReq} addressbookMultiget := addressbookMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 { if multiGet == nil || len(multiGet.Paths) == 0 {
href := internal.Href{Path: path} href := internal.Href{Path: path}
addressbookMultiget.Hrefs = []internal.Href{href} addressbookMultiget.Hrefs = []internal.Href{href}
} else { } else {
@ -302,7 +330,7 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
req.Header.Add("Depth", "1") req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx)) ms, err := c.ic.DoMultiStatus(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -310,29 +338,29 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
return decodeAddressList(ms) return decodeAddressList(ms)
} }
func populateAddressObject(ao *AddressObject, h http.Header) error { func populateAddressObject(ao *AddressObject, resp *http.Response) error {
if loc := h.Get("Location"); loc != "" { if loc := resp.Header.Get("Location"); loc != "" {
u, err := url.Parse(loc) u, err := url.Parse(loc)
if err != nil { if err != nil {
return err return err
} }
ao.Path = u.Path ao.Path = u.Path
} }
if etag := h.Get("ETag"); etag != "" { if etag := resp.Header.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag) etag, err := strconv.Unquote(etag)
if err != nil { if err != nil {
return err return err
} }
ao.ETag = etag ao.ETag = etag
} }
if contentLength := h.Get("Content-Length"); contentLength != "" { if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
n, err := strconv.ParseInt(contentLength, 10, 64) n, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil { if err != nil {
return err return err
} }
ao.ContentLength = n ao.ContentLength = n
} }
if lastModified := h.Get("Last-Modified"); lastModified != "" { if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified) t, err := http.ParseTime(lastModified)
if err != nil { if err != nil {
return err return err
@ -343,14 +371,14 @@ func populateAddressObject(ao *AddressObject, h http.Header) error {
return nil return nil
} }
func (c *Client) GetAddressObject(ctx context.Context, path string) (*AddressObject, error) { func (c *Client) GetAddressObject(path string) (*AddressObject, error) {
req, err := c.ic.NewRequest(http.MethodGet, path, nil) req, err := c.ic.NewRequest(http.MethodGet, path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", vcard.MIMEType) req.Header.Set("Accept", vcard.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -373,13 +401,13 @@ func (c *Client) GetAddressObject(ctx context.Context, path string) (*AddressObj
Path: resp.Request.URL.Path, Path: resp.Request.URL.Path,
Card: card, Card: card,
} }
if err := populateAddressObject(ao, resp.Header); err != nil { if err := populateAddressObject(ao, resp); err != nil {
return nil, err return nil, err
} }
return ao, nil return ao, nil
} }
func (c *Client) PutAddressObject(ctx context.Context, path string, card vcard.Card) (*AddressObject, error) { func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject, error) {
// TODO: add support for If-None-Match and If-Match // TODO: add support for If-None-Match and If-Match
// TODO: some servers want a Content-Length header, so we can't stream the // TODO: some servers want a Content-Length header, so we can't stream the
@ -404,14 +432,14 @@ func (c *Client) PutAddressObject(ctx context.Context, path string, card vcard.C
} }
req.Header.Set("Content-Type", vcard.MIMEType) req.Header.Set("Content-Type", vcard.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp.Body.Close() resp.Body.Close()
ao := &AddressObject{Path: path} ao := &AddressObject{Path: path}
if err := populateAddressObject(ao, resp.Header); err != nil { if err := populateAddressObject(ao, resp); err != nil {
return nil, err return nil, err
} }
return ao, nil return ao, nil
@ -419,7 +447,7 @@ func (c *Client) PutAddressObject(ctx context.Context, path string, card vcard.C
// SyncCollection performs a collection synchronization operation on the // SyncCollection performs a collection synchronization operation on the
// specified resource, as defined in RFC 6578. // specified resource, as defined in RFC 6578.
func (c *Client) SyncCollection(ctx context.Context, path string, query *SyncQuery) (*SyncResponse, error) { func (c *Client) SyncCollection(path string, query *SyncQuery) (*SyncResponse, error) {
var limit *internal.Limit var limit *internal.Limit
if query.Limit > 0 { if query.Limit > 0 {
limit = &internal.Limit{NResults: uint(query.Limit)} limit = &internal.Limit{NResults: uint(query.Limit)}
@ -430,7 +458,7 @@ func (c *Client) SyncCollection(ctx context.Context, path string, query *SyncQue
return nil, err return nil, err
} }
ms, err := c.ic.SyncCollection(ctx, path, query.SyncToken, internal.DepthOne, limit, propReq) ms, err := c.ic.SyncCollection(path, query.SyncToken, internal.DepthOne, limit, propReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -211,11 +211,3 @@ func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return d.DecodeElement(v, &start) 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?
}

View File

@ -16,6 +16,8 @@ import (
"github.com/emersion/go-webdav/internal" "github.com/emersion/go-webdav/internal"
) )
// TODO: add support for multiple address books
type PutAddressObjectOptions struct { type PutAddressObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite // IfNoneMatch indicates that the client does not want to overwrite
// an existing resource. // an existing resource.
@ -27,15 +29,12 @@ type PutAddressObjectOptions struct {
// Backend is a CardDAV server backend. // Backend is a CardDAV server backend.
type Backend interface { type Backend interface {
AddressBookHomeSetPath(ctx context.Context) (string, error) AddressbookHomeSetPath(ctx context.Context) (string, error)
ListAddressBooks(ctx context.Context) ([]AddressBook, error) AddressBook(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) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error)
ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error) ListAddressObjects(ctx context.Context, req *AddressDataRequest) ([]AddressObject, error)
QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error) QueryAddressObjects(ctx context.Context, query *AddressBookQuery) ([]AddressObject, error)
PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, error) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (loc string, err error)
DeleteAddressObject(ctx context.Context, path string) error DeleteAddressObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend webdav.UserPrincipalBackend
@ -75,7 +74,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Backend: h.Backend, Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"), Prefix: strings.TrimSuffix(h.Prefix, "/"),
} }
hh := internal.Handler{Backend: &b} hh := internal.Handler{&b}
hh.ServeHTTP(w, r) hh.ServeHTTP(w, r)
} }
@ -91,7 +90,7 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) error {
} }
if report.Query != nil { if report.Query != nil {
return h.handleQuery(r, w, report.Query) return h.handleQuery(r.Context(), w, report.Query)
} else if report.Multiget != nil { } else if report.Multiget != nil {
return h.handleMultiget(r.Context(), w, report.Multiget) return h.handleMultiget(r.Context(), w, report.Multiget)
} }
@ -153,7 +152,7 @@ func decodeAddressDataReq(addressData *addressDataReq) (*AddressDataRequest, err
return req, nil return req, nil
} }
func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *addressbookQuery) error { func (h *Handler) handleQuery(ctx context.Context, w http.ResponseWriter, query *addressbookQuery) error {
var q AddressBookQuery var q AddressBookQuery
if query.Prop != nil { if query.Prop != nil {
var addressData addressDataReq var addressData addressDataReq
@ -170,7 +169,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *add
for _, el := range query.Filter.Props { for _, el := range query.Filter.Props {
pf, err := decodePropFilter(&el) pf, err := decodePropFilter(&el)
if err != nil { if err != nil {
return &internal.HTTPError{Code: http.StatusBadRequest, Err: err} return &internal.HTTPError{http.StatusBadRequest, err}
} }
q.PropFilters = append(q.PropFilters, *pf) q.PropFilters = append(q.PropFilters, *pf)
} }
@ -181,7 +180,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *add
} }
} }
aos, err := h.Backend.QueryAddressObjects(r.Context(), r.URL.Path, &q) aos, err := h.Backend.QueryAddressObjects(ctx, &q)
if err != nil { if err != nil {
return err return err
} }
@ -197,7 +196,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *add
AllProp: query.AllProp, AllProp: query.AllProp,
PropName: query.PropName, PropName: query.PropName,
} }
resp, err := b.propFindAddressObject(r.Context(), &propfind, &ao) resp, err := b.propFindAddressObject(ctx, &propfind, &ao)
if err != nil { if err != nil {
return err return err
} }
@ -339,12 +338,6 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
var resps []internal.Response var resps []internal.Response
switch resType { switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal: case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context()) principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil { if err != nil {
@ -372,7 +365,7 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
} }
} }
case resourceTypeAddressBookHomeSet: case resourceTypeAddressBookHomeSet:
homeSetPath, err := b.Backend.AddressBookHomeSetPath(r.Context()) homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -392,21 +385,24 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
} }
} }
case resourceTypeAddressBook: case resourceTypeAddressBook:
ab, err := b.Backend.GetAddressBook(r.Context(), r.URL.Path) // TODO for multiple address books, look through all of them
ab, err := b.Backend.AddressBook(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := b.propFindAddressBook(r.Context(), propfind, ab) if r.URL.Path == ab.Path {
if err != nil { resp, err := b.propFindAddressBook(r.Context(), propfind, ab)
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllAddressObjects(r.Context(), propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resps = append(resps, resps_...) 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: case resourceTypeAddressObject:
ao, err := b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq) ao, err := b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
@ -424,45 +420,36 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
return internal.NewMultiStatus(resps...), nil 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) { func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx) principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
homeSetPath, err := b.Backend.AddressbookHomeSetPath(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{ props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{ internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
Href: internal.Href{Path: principalPath}, return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
}), },
addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) { 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 return &addressbookHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
}, },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
} }
return internal.NewPropFindResponse(principalPath, propfind, props) return internal.NewPropFindResponse(principalPath, propfind, props)
} }
func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) { func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(ctx) principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
homeSetPath, err := b.Backend.AddressbookHomeSetPath(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -470,13 +457,11 @@ func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFi
// TODO anything else to return here? // TODO anything else to return here?
props := map[xml.Name]internal.PropFindFunc{ props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) { 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 return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
}, },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
} }
return internal.NewPropFindResponse(homeSetPath, propfind, props) return internal.NewPropFindResponse(homeSetPath, propfind, props)
} }
@ -490,52 +475,51 @@ func (b *backend) propFindAddressBook(ctx context.Context, propfind *internal.Pr
} }
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
}, },
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, addressBookName)), internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
supportedAddressDataName: internal.PropFindValue(&supportedAddressData{ return internal.NewResourceType(internal.CollectionName, addressBookName), nil
Types: []addressDataType{ },
{ContentType: vcard.MIMEType, Version: "3.0"}, internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
{ContentType: vcard.MIMEType, Version: "4.0"}, return &internal.DisplayName{Name: ab.Name}, nil
}, },
}), addressBookDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
return &addressbookDescription{Description: ab.Description}, nil
},
supportedAddressDataName: func(*internal.RawXMLValue) (interface{}, error) {
return &supportedAddressData{
Types: []addressDataType{
{ContentType: vcard.MIMEType, Version: "3.0"},
{ContentType: vcard.MIMEType, Version: "4.0"},
},
}, nil
},
} }
if ab.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 { if ab.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{ props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
Size: ab.MaxResourceSize, return &maxResourceSize{Size: ab.MaxResourceSize}, nil
}) }
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
} }
return internal.NewPropFindResponse(ab.Path, propfind, props) return internal.NewPropFindResponse(ab.Path, propfind, props)
} }
func (b *backend) propFindAllAddressBooks(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) { func (b *backend) propFindAllAddressBooks(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) {
abs, err := b.Backend.ListAddressBooks(ctx) // TODO iterate over all address books once having multiple is supported
ab, err := b.Backend.AddressBook(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
abs := []*AddressBook{ab}
var resps []internal.Response var resps []internal.Response
for _, ab := range abs { for _, ab := range abs {
resp, err := b.propFindAddressBook(ctx, propfind, &ab) resp, err := b.propFindAddressBook(ctx, propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resps = append(resps, *resp) resps = append(resps, *resp)
if recurse { if recurse {
resps_, err := b.propFindAllAddressObjects(ctx, propfind, &ab) resps_, err := b.propFindAllAddressObjects(ctx, propfind, ab)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -554,9 +538,9 @@ func (b *backend) propFindAddressObject(ctx context.Context, propfind *internal.
} }
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
}, },
internal.GetContentTypeName: internal.PropFindValue(&internal.GetContentType{ internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
Type: vcard.MIMEType, return &internal.GetContentType{Type: vcard.MIMEType}, nil
}), },
// TODO: address-data can only be used in REPORT requests // TODO: address-data can only be used in REPORT requests
addressDataName: func(*internal.RawXMLValue) (interface{}, error) { addressDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer var buf bytes.Buffer
@ -569,20 +553,20 @@ func (b *backend) propFindAddressObject(ctx context.Context, propfind *internal.
} }
if ao.ContentLength > 0 { if ao.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{ props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
Length: ao.ContentLength, return &internal.GetContentLength{Length: ao.ContentLength}, nil
}) }
} }
if !ao.ModTime.IsZero() { if !ao.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{ props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
LastModified: internal.Time(ao.ModTime), return &internal.GetLastModified{LastModified: internal.Time(ao.ModTime)}, nil
}) }
} }
if ao.ETag != "" { if ao.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{ props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
ETag: internal.ETag(ao.ETag), return &internal.GetETag{ETag: internal.ETag(ao.ETag)}, nil
}) }
} }
return internal.NewPropFindResponse(ao.Path, propfind, props) return internal.NewPropFindResponse(ao.Path, propfind, props)
@ -590,7 +574,7 @@ func (b *backend) propFindAddressObject(ctx context.Context, propfind *internal.
func (b *backend) propFindAllAddressObjects(ctx context.Context, propfind *internal.PropFind, ab *AddressBook) ([]internal.Response, error) { func (b *backend) propFindAllAddressObjects(ctx context.Context, propfind *internal.PropFind, ab *AddressBook) ([]internal.Response, error) {
var dataReq AddressDataRequest var dataReq AddressDataRequest
aos, err := b.Backend.ListAddressObjects(ctx, ab.Path, &dataReq) aos, err := b.Backend.ListAddressObjects(ctx, &dataReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -607,7 +591,7 @@ func (b *backend) propFindAllAddressObjects(ctx context.Context, propfind *inter
} }
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) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(r.Context()) homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -646,7 +630,7 @@ func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*
return resp, nil return resp, nil
} }
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error { func (b *backend) Put(r *http.Request) (*internal.Href, error) {
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match")) ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match")) ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
@ -657,86 +641,46 @@ func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil { if err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err) return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: malformed Content-Type: %v", err)
} }
if t != vcard.MIMEType { if t != vcard.MIMEType {
// TODO: send CARDDAV:supported-address-data error // TODO: send CARDDAV:supported-address-data error
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unsupporetd Content-Type %q", t) return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: unsupporetd Content-Type %q", t)
} }
// TODO: check CARDDAV:max-resource-size precondition // TODO: check CARDDAV:max-resource-size precondition
card, err := vcard.NewDecoder(r.Body).Decode() card, err := vcard.NewDecoder(r.Body).Decode()
if err != nil { if err != nil {
// TODO: send CARDDAV:valid-address-data error // TODO: send CARDDAV:valid-address-data error
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err) return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
} }
// TODO: add support for the CARDDAV:no-uid-conflict error // TODO: add support for the CARDDAV:no-uid-conflict error
ao, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts) loc, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
if err != nil { if err != nil {
return err return nil, err
}
if ao.ETag != "" {
w.Header().Set("ETag", internal.ETag(ao.ETag).String())
}
if !ao.ModTime.IsZero() {
w.Header().Set("Last-Modified", ao.ModTime.UTC().Format(http.TimeFormat))
}
if ao.Path != "" {
w.Header().Set("Location", ao.Path)
} }
// TODO: http.StatusNoContent if the resource already existed return &internal.Href{Path: loc}, nil
w.WriteHeader(http.StatusCreated)
return nil
} }
func (b *backend) Delete(r *http.Request) error { func (b *backend) Delete(r *http.Request) error {
switch b.resourceTypeAtPath(r.URL.Path) { return b.Backend.DeleteAddressObject(r.Context(), 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 { func (b *backend) Mkcol(r *http.Request) error {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeAddressBook { return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation unsupported")
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) { func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "carddav: Copy not implemented") panic("TODO")
} }
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) { func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "carddav: Move not implemented") panic("TODO")
} }
// PreconditionType as defined in https://tools.ietf.org/rfcmarkup?doc=6352#section-6.3.2.1 // https://tools.ietf.org/rfcmarkup?doc=6352#section-6.3.2.1
type PreconditionType string type PreconditionType string
const ( const (
@ -747,7 +691,7 @@ const (
) )
func NewPreconditionError(err PreconditionType) error { func NewPreconditionError(err PreconditionType) error {
name := xml.Name{Space: "urn:ietf:params:xml:ns:carddav", Local: string(err)} name := xml.Name{"urn:ietf:params:xml:ns:carddav", string(err)}
elem := internal.NewRawXMLElement(name, nil, nil) elem := internal.NewRawXMLElement(name, nil, nil)
return &internal.HTTPError{ return &internal.HTTPError{
Code: 409, Code: 409,

View File

@ -1,7 +1,6 @@
package webdav package webdav
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -40,11 +39,6 @@ type Client struct {
ic *internal.Client 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) { func NewClient(c HTTPClient, endpoint string) (*Client, error) {
ic, err := internal.NewClient(c, endpoint) ic, err := internal.NewClient(c, endpoint)
if err != nil { if err != nil {
@ -53,13 +47,12 @@ func NewClient(c HTTPClient, endpoint string) (*Client, error) {
return &Client{ic}, nil return &Client{ic}, nil
} }
// FindCurrentUserPrincipal finds the current user's principal path. func (c *Client) FindCurrentUserPrincipal() (string, error) {
func (c *Client) FindCurrentUserPrincipal(ctx context.Context) (string, error) {
propfind := internal.NewPropNamePropFind(internal.CurrentUserPrincipalName) propfind := internal.NewPropNamePropFind(internal.CurrentUserPrincipalName)
// TODO: consider retrying on the root URI "/" if this fails, as suggested // TODO: consider retrying on the root URI "/" if this fails, as suggested
// by the RFC? // by the RFC?
resp, err := c.ic.PropFindFlat(ctx, "", propfind) resp, err := c.ic.PropFindFlat("", propfind)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -128,23 +121,21 @@ func fileInfoFromResponse(resp *internal.Response) (*FileInfo, error) {
return fi, nil return fi, nil
} }
// Stat fetches a FileInfo for a single file. func (c *Client) Stat(name string) (*FileInfo, error) {
func (c *Client) Stat(ctx context.Context, name string) (*FileInfo, error) { resp, err := c.ic.PropFindFlat(name, fileInfoPropFind)
resp, err := c.ic.PropFindFlat(ctx, name, fileInfoPropFind)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return fileInfoFromResponse(resp) return fileInfoFromResponse(resp)
} }
// Open fetches a file's contents. func (c *Client) Open(name string) (io.ReadCloser, error) {
func (c *Client) Open(ctx context.Context, name string) (io.ReadCloser, error) {
req, err := c.ic.NewRequest(http.MethodGet, name, nil) req, err := c.ic.NewRequest(http.MethodGet, name, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -152,14 +143,13 @@ func (c *Client) Open(ctx context.Context, name string) (io.ReadCloser, error) {
return resp.Body, nil return resp.Body, nil
} }
// ReadDir lists files in a directory. func (c *Client) Readdir(name string, recursive bool) ([]FileInfo, error) {
func (c *Client) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) {
depth := internal.DepthOne depth := internal.DepthOne
if recursive { if recursive {
depth = internal.DepthInfinity depth = internal.DepthInfinity
} }
ms, err := c.ic.PropFind(ctx, name, depth, fileInfoPropFind) ms, err := c.ic.PropFind(name, depth, fileInfoPropFind)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -192,8 +182,7 @@ func (fw *fileWriter) Close() error {
return <-fw.done return <-fw.done
} }
// Create writes a file's contents. func (c *Client) Create(name string) (io.WriteCloser, error) {
func (c *Client) Create(ctx context.Context, name string) (io.WriteCloser, error) {
pr, pw := io.Pipe() pr, pw := io.Pipe()
req, err := c.ic.NewRequest(http.MethodPut, name, pr) req, err := c.ic.NewRequest(http.MethodPut, name, pr)
@ -204,7 +193,7 @@ func (c *Client) Create(ctx context.Context, name string) (io.WriteCloser, error
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
done <- err done <- err
return return
@ -216,15 +205,13 @@ func (c *Client) Create(ctx context.Context, name string) (io.WriteCloser, error
return &fileWriter{pw, done}, nil return &fileWriter{pw, done}, nil
} }
// RemoveAll deletes a file. If the file is a directory, all of its descendants func (c *Client) RemoveAll(name string) error {
// are recursively deleted as well.
func (c *Client) RemoveAll(ctx context.Context, name string) error {
req, err := c.ic.NewRequest(http.MethodDelete, name, nil) req, err := c.ic.NewRequest(http.MethodDelete, name, nil)
if err != nil { if err != nil {
return err return err
} }
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return err return err
} }
@ -232,14 +219,13 @@ func (c *Client) RemoveAll(ctx context.Context, name string) error {
return nil return nil
} }
// Mkdir creates a new directory. func (c *Client) Mkdir(name string) error {
func (c *Client) Mkdir(ctx context.Context, name string) error {
req, err := c.ic.NewRequest("MKCOL", name, nil) req, err := c.ic.NewRequest("MKCOL", name, nil)
if err != nil { if err != nil {
return err return err
} }
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return err return err
} }
@ -247,30 +233,16 @@ func (c *Client) Mkdir(ctx context.Context, name string) error {
return nil return nil
} }
// Copy copies a file. func (c *Client) CopyAll(name, dest string, overwrite bool) error {
//
// 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) req, err := c.ic.NewRequest("COPY", name, nil)
if err != nil { if err != nil {
return err return err
} }
depth := internal.DepthInfinity
if options.NoRecursive {
depth = internal.DepthZero
}
req.Header.Set("Destination", c.ic.ResolveHref(dest).String()) req.Header.Set("Destination", c.ic.ResolveHref(dest).String())
req.Header.Set("Overwrite", internal.FormatOverwrite(!options.NoOverwrite)) req.Header.Set("Overwrite", internal.FormatOverwrite(overwrite))
req.Header.Set("Depth", depth.String())
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return err return err
} }
@ -278,21 +250,16 @@ func (c *Client) Copy(ctx context.Context, name, dest string, options *CopyOptio
return nil return nil
} }
// Move moves a file. func (c *Client) MoveAll(name, dest string, overwrite bool) error {
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) req, err := c.ic.NewRequest("MOVE", name, nil)
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Destination", c.ic.ResolveHref(dest).String()) req.Header.Set("Destination", c.ic.ResolveHref(dest).String())
req.Header.Set("Overwrite", internal.FormatOverwrite(!options.NoOverwrite)) req.Header.Set("Overwrite", internal.FormatOverwrite(overwrite))
resp, err := c.ic.Do(req.WithContext(ctx)) resp, err := c.ic.Do(req)
if err != nil { if err != nil {
return err return err
} }

View File

@ -30,3 +30,24 @@ type groupMembership struct {
XMLName xml.Name `xml:"DAV: group-membership"` XMLName xml.Name `xml:"DAV: group-membership"`
Hrefs []internal.Href `xml:"href"` Hrefs []internal.Href `xml:"href"`
} }
// 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
}

View File

@ -1,7 +1,6 @@
package webdav package webdav
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"mime" "mime"
@ -14,11 +13,8 @@ import (
"github.com/emersion/go-webdav/internal" "github.com/emersion/go-webdav/internal"
) )
// LocalFileSystem implements FileSystem for a local directory.
type LocalFileSystem string type LocalFileSystem string
var _ FileSystem = LocalFileSystem("")
func (fs LocalFileSystem) localPath(name string) (string, error) { func (fs LocalFileSystem) localPath(name string) (string, error) {
if (filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0) || strings.Contains(name, "\x00") { if (filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0) || strings.Contains(name, "\x00") {
return "", internal.HTTPErrorf(http.StatusBadRequest, "webdav: invalid character in path") return "", internal.HTTPErrorf(http.StatusBadRequest, "webdav: invalid character in path")
@ -38,7 +34,7 @@ func (fs LocalFileSystem) externalPath(name string) (string, error) {
return "/" + filepath.ToSlash(rel), nil return "/" + filepath.ToSlash(rel), nil
} }
func (fs LocalFileSystem) Open(ctx context.Context, name string) (io.ReadCloser, error) { func (fs LocalFileSystem) Open(name string) (io.ReadCloser, error) {
p, err := fs.localPath(name) p, err := fs.localPath(name)
if err != nil { if err != nil {
return nil, err return nil, err
@ -63,31 +59,19 @@ func fileInfoFromOS(p string, fi os.FileInfo) *FileInfo {
} }
} }
func errFromOS(err error) error { func (fs LocalFileSystem) Stat(name string) (*FileInfo, 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) p, err := fs.localPath(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fi, err := os.Stat(p) fi, err := os.Stat(p)
if err != nil { if err != nil {
return nil, errFromOS(err) return nil, err
} }
return fileInfoFromOS(name, fi), nil return fileInfoFromOS(name, fi), nil
} }
func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) { func (fs LocalFileSystem) Readdir(name string, recursive bool) ([]FileInfo, error) {
path, err := fs.localPath(name) path, err := fs.localPath(name)
if err != nil { if err != nil {
return nil, err return nil, err
@ -111,60 +95,18 @@ func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bo
} }
return nil return nil
}) })
return l, errFromOS(err) return l, err
} }
func (fs LocalFileSystem) Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fi *FileInfo, created bool, err error) { func (fs LocalFileSystem) Create(name string) (io.WriteCloser, error) {
p, err := fs.localPath(name) p, err := fs.localPath(name)
if err != nil { if err != nil {
return nil, false, err return nil, err
} }
fi, _ = fs.Stat(ctx, name) return os.Create(p)
created = fi == nil
etag := ""
if fi != nil {
etag = fi.ETag
}
if opts.IfMatch.IsSet() {
if ok, err := opts.IfMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if !ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-Match condition failed"))
}
}
if opts.IfNoneMatch.IsSet() {
if ok, err := opts.IfNoneMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-None-Match condition failed"))
}
}
wc, err := os.Create(p)
if err != nil {
return nil, false, errFromOS(err)
}
defer wc.Close()
if _, err := io.Copy(wc, body); err != nil {
os.Remove(p)
return nil, false, err
}
if err := wc.Close(); err != nil {
os.Remove(p)
return nil, false, err
}
fi, err = fs.Stat(ctx, name)
if err != nil {
return nil, false, err
}
return fi, created, err
} }
func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error { func (fs LocalFileSystem) RemoveAll(name string) error {
p, err := fs.localPath(name) p, err := fs.localPath(name)
if err != nil { if err != nil {
return err return err
@ -173,32 +115,31 @@ func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error {
// WebDAV semantics are that it should return a "404 Not Found" error in // 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. // case the resource doesn't exist. We need to Stat before RemoveAll.
if _, err = os.Stat(p); err != nil { if _, err = os.Stat(p); err != nil {
return errFromOS(err) return err
} }
return errFromOS(os.RemoveAll(p)) return os.RemoveAll(p)
} }
func (fs LocalFileSystem) Mkdir(ctx context.Context, name string) error { func (fs LocalFileSystem) Mkdir(name string) error {
p, err := fs.localPath(name) p, err := fs.localPath(name)
if err != nil { if err != nil {
return err return err
} }
return errFromOS(os.Mkdir(p, 0755)) return os.Mkdir(p, 0755)
} }
func copyRegularFile(src, dst string, perm os.FileMode) error { func copyRegularFile(src, dst string, perm os.FileMode) error {
srcFile, err := os.Open(src) srcFile, err := os.Open(src)
if err != nil { if err != nil {
return errFromOS(err) return err
} }
defer srcFile.Close() defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm) dstFile, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if os.IsNotExist(err) { if err != nil {
return NewHTTPError(http.StatusConflict, err) // TODO: send http.StatusConflict on os.IsNotExist
} else if err != nil { return err
return errFromOS(err)
} }
defer dstFile.Close() defer dstFile.Close()
@ -209,7 +150,7 @@ func copyRegularFile(src, dst string, perm os.FileMode) error {
return dstFile.Close() return dstFile.Close()
} }
func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *CopyOptions) (created bool, err error) { func (fs LocalFileSystem) Copy(src, dst string, recursive, overwrite bool) (created bool, err error) {
srcPath, err := fs.localPath(src) srcPath, err := fs.localPath(src)
if err != nil { if err != nil {
return false, err return false, err
@ -224,21 +165,21 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
srcInfo, err := os.Stat(srcPath) srcInfo, err := os.Stat(srcPath)
if err != nil { if err != nil {
return false, errFromOS(err) return false, err
} }
srcPerm := srcInfo.Mode() & os.ModePerm srcPerm := srcInfo.Mode() & os.ModePerm
if _, err := os.Stat(dstPath); err != nil { if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return false, errFromOS(err) return false, err
} }
created = true created = true
} else { } else {
if options.NoOverwrite { if !overwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist) return false, os.ErrExist
} }
if err := os.RemoveAll(dstPath); err != nil { if err := os.RemoveAll(dstPath); err != nil {
return false, errFromOS(err) return false, err
} }
} }
@ -249,7 +190,7 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
if fi.IsDir() { if fi.IsDir() {
if err := os.Mkdir(dstPath, srcPerm); err != nil { if err := os.Mkdir(dstPath, srcPerm); err != nil {
return errFromOS(err) return err
} }
} else { } else {
if err := copyRegularFile(srcPath, dstPath, srcPerm); err != nil { if err := copyRegularFile(srcPath, dstPath, srcPerm); err != nil {
@ -257,19 +198,19 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
} }
} }
if fi.IsDir() && options.NoRecursive { if fi.IsDir() && !recursive {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
}) })
if err != nil { if err != nil {
return false, errFromOS(err) return false, err
} }
return created, nil return created, nil
} }
func (fs LocalFileSystem) Move(ctx context.Context, src, dst string, options *MoveOptions) (created bool, err error) { func (fs LocalFileSystem) MoveAll(src, dst string, overwrite bool) (created bool, err error) {
srcPath, err := fs.localPath(src) srcPath, err := fs.localPath(src)
if err != nil { if err != nil {
return false, err return false, err
@ -281,21 +222,23 @@ func (fs LocalFileSystem) Move(ctx context.Context, src, dst string, options *Mo
if _, err := os.Stat(dstPath); err != nil { if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return false, errFromOS(err) return false, err
} }
created = true created = true
} else { } else {
if options.NoOverwrite { if !overwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist) return false, os.ErrExist
} }
if err := os.RemoveAll(dstPath); err != nil { if err := os.RemoveAll(dstPath); err != nil {
return false, errFromOS(err) return false, err
} }
} }
if err := os.Rename(srcPath, dstPath); err != nil { if err := os.Rename(srcPath, dstPath); err != nil {
return false, errFromOS(err) return false, err
} }
return created, nil return created, nil
} }
var _ FileSystem = LocalFileSystem("")

4
go.mod
View File

@ -3,6 +3,6 @@ module github.com/emersion/go-webdav
go 1.13 go 1.13
require ( require (
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7
) )

12
go.sum
View File

@ -1,6 +1,6 @@
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= 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-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=

View File

@ -2,12 +2,10 @@ package internal
import ( import (
"bytes" "bytes"
"context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"mime" "mime"
"net"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
@ -15,42 +13,6 @@ import (
"unicode" "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. // HTTPClient performs HTTP requests. It's implemented by *http.Client.
type HTTPClient interface { type HTTPClient interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
@ -169,7 +131,7 @@ func (c *Client) DoMultiStatus(req *http.Request) (*MultiStatus, error) {
return &ms, nil return &ms, nil
} }
func (c *Client) PropFind(ctx context.Context, path string, depth Depth, propfind *PropFind) (*MultiStatus, error) { func (c *Client) PropFind(path string, depth Depth, propfind *PropFind) (*MultiStatus, error) {
req, err := c.NewXMLRequest("PROPFIND", path, propfind) req, err := c.NewXMLRequest("PROPFIND", path, propfind)
if err != nil { if err != nil {
return nil, err return nil, err
@ -177,12 +139,12 @@ func (c *Client) PropFind(ctx context.Context, path string, depth Depth, propfin
req.Header.Add("Depth", depth.String()) req.Header.Add("Depth", depth.String())
return c.DoMultiStatus(req.WithContext(ctx)) return c.DoMultiStatus(req)
} }
// PropfindFlat performs a PROPFIND request with a zero depth. // PropfindFlat performs a PROPFIND request with a zero depth.
func (c *Client) PropFindFlat(ctx context.Context, path string, propfind *PropFind) (*Response, error) { func (c *Client) PropFindFlat(path string, propfind *PropFind) (*Response, error) {
ms, err := c.PropFind(ctx, path, DepthZero, propfind) ms, err := c.PropFind(path, DepthZero, propfind)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -212,13 +174,13 @@ func parseCommaSeparatedSet(values []string, upper bool) map[string]bool {
return m return m
} }
func (c *Client) Options(ctx context.Context, path string) (classes map[string]bool, methods map[string]bool, err error) { func (c *Client) Options(path string) (classes map[string]bool, methods map[string]bool, err error) {
req, err := c.NewRequest(http.MethodOptions, path, nil) req, err := c.NewRequest(http.MethodOptions, path, nil)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
resp, err := c.Do(req.WithContext(ctx)) resp, err := c.Do(req)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -234,7 +196,7 @@ func (c *Client) Options(ctx context.Context, path string) (classes map[string]b
} }
// SyncCollection perform a `sync-collection` REPORT operation on a resource // 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) { func (c *Client) SyncCollection(path, syncToken string, level Depth, limit *Limit, prop *Prop) (*MultiStatus, error) {
q := SyncCollectionQuery{ q := SyncCollectionQuery{
SyncToken: syncToken, SyncToken: syncToken,
SyncLevel: level.String(), SyncLevel: level.String(),
@ -247,7 +209,7 @@ func (c *Client) SyncCollection(ctx context.Context, path, syncToken string, lev
return nil, err return nil, err
} }
ms, err := c.DoMultiStatus(req.WithContext(ctx)) ms, err := c.DoMultiStatus(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -1,7 +1,6 @@
package internal package internal
import ( import (
"encoding/base64"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
@ -23,8 +22,6 @@ var (
GetETagName = xml.Name{Namespace, "getetag"} GetETagName = xml.Name{Namespace, "getetag"}
CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"} CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"}
CurrentUserPrivilegeSetName = xml.Name{Namespace, "current-user-privilege-set"}
) )
type Status struct { type Status struct {
@ -146,10 +143,7 @@ func (resp *Response) Err() error {
return nil return nil
} }
var err error var err error = resp.Error
if resp.Error != nil {
err = resp.Error
}
if resp.ResponseDescription != "" { if resp.ResponseDescription != "" {
if err != nil { if err != nil {
err = fmt.Errorf("%v (%w)", resp.ResponseDescription, err) err = fmt.Errorf("%v (%w)", resp.ResponseDescription, err)
@ -354,7 +348,7 @@ type Time time.Time
func (t *Time) UnmarshalText(b []byte) error { func (t *Time) UnmarshalText(b []byte) error {
tt, err := http.ParseTime(string(b)) tt, err := http.ParseTime(string(b))
if err != nil { if err != nil {
return errors.New(err.Error() + " : time_data : " + base64.StdEncoding.EncodeToString(b)) return err
} }
*t = Time(tt) *t = Time(tt)
return nil return nil
@ -420,30 +414,6 @@ type CurrentUserPrincipal struct {
Unauthenticated *struct{} `xml:"unauthenticated,omitempty"` Unauthenticated *struct{} `xml:"unauthenticated,omitempty"`
} }
type CurrentUserPrivilegeSet struct {
XMLName xml.Name `xml:"DAV: current-user-privilege-set"`
Privilege []Privilege `xml:"privilege"`
}
type Privilege struct {
XMLName xml.Name `xml:"DAV: privilege"`
Read *struct{} `xml:"DAV: read,omitempty"`
All *struct{} `xml:"DAV: all,omitempty"`
Write *struct{} `xml:"DAV: write,omitempty"`
WriteProperties *struct{} `xml:"DAV: write-properties,omitempty"`
WriteContent *struct{} `xml:"DAV: write-content,omitempty"`
}
func NewAllPrivileges() []Privilege {
return []Privilege{
{Read: &struct{}{}},
{All: &struct{}{}},
{Write: &struct{}{}},
{WriteProperties: &struct{}{}},
{WriteContent: &struct{}{}},
}
}
// https://tools.ietf.org/html/rfc4918#section-14.19 // https://tools.ietf.org/html/rfc4918#section-14.19
type PropertyUpdate struct { type PropertyUpdate struct {
XMLName xml.Name `xml:"DAV: propertyupdate"` XMLName xml.Name `xml:"DAV: propertyupdate"`

View File

@ -4,7 +4,6 @@ import (
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
"io"
"mime" "mime"
"net/http" "net/http"
"net/url" "net/url"
@ -28,13 +27,9 @@ func ServeError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), code) 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 { func DecodeXMLRequest(r *http.Request, v interface{}) error {
if !isContentXML(r.Header) { t, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if t != "application/xml" && t != "text/xml" {
return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request") return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request")
} }
@ -44,13 +39,8 @@ func DecodeXMLRequest(r *http.Request, v interface{}) error {
return nil return nil
} }
func IsRequestBodyEmpty(r *http.Request) bool {
_, err := r.Body.Read(nil)
return err == io.EOF
}
func ServeXML(w http.ResponseWriter) *xml.Encoder { func ServeXML(w http.ResponseWriter) *xml.Encoder {
w.Header().Add("Content-Type", "application/xml; charset=\"utf-8\"") w.Header().Add("Content-Type", "text/xml; charset=\"utf-8\"")
w.Write([]byte(xml.Header)) w.Write([]byte(xml.Header))
return xml.NewEncoder(w) return xml.NewEncoder(w)
} }
@ -66,7 +56,7 @@ type Backend interface {
HeadGet(w http.ResponseWriter, r *http.Request) error HeadGet(w http.ResponseWriter, r *http.Request) error
PropFind(r *http.Request, pf *PropFind, depth Depth) (*MultiStatus, error) PropFind(r *http.Request, pf *PropFind, depth Depth) (*MultiStatus, error)
PropPatch(r *http.Request, pu *PropertyUpdate) (*Response, error) PropPatch(r *http.Request, pu *PropertyUpdate) (*Response, error)
Put(w http.ResponseWriter, r *http.Request) error Put(r *http.Request) (*Href, error)
Delete(r *http.Request) error Delete(r *http.Request) error
Mkcol(r *http.Request) error Mkcol(r *http.Request) error
Copy(r *http.Request, dest *Href, recursive, overwrite bool) (created bool, err error) Copy(r *http.Request, dest *Href, recursive, overwrite bool) (created bool, err error)
@ -88,7 +78,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case http.MethodGet, http.MethodHead: case http.MethodGet, http.MethodHead:
err = h.Backend.HeadGet(w, r) err = h.Backend.HeadGet(w, r)
case http.MethodPut: case http.MethodPut:
err = h.Backend.Put(w, r) 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)
}
case http.MethodDelete: case http.MethodDelete:
// TODO: send a multistatus in case of partial failure // TODO: send a multistatus in case of partial failure
err = h.Backend.Delete(r) err = h.Backend.Delete(r)
@ -125,22 +125,14 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) error {
w.Header().Add("DAV", strings.Join(caps, ", ")) w.Header().Add("DAV", strings.Join(caps, ", "))
w.Header().Add("Allow", strings.Join(allow, ", ")) w.Header().Add("Allow", strings.Join(allow, ", "))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusNoContent)
return nil return nil
} }
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error { func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
var propfind PropFind var propfind PropFind
if isContentXML(r.Header) { if err := DecodeXMLRequest(r, &propfind); err != nil {
if err := DecodeXMLRequest(r, &propfind); err != nil { return err
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 depth := DepthInfinity
@ -162,17 +154,13 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
type PropFindFunc func(raw *RawXMLValue) (interface{}, error) type PropFindFunc func(raw *RawXMLValue) (interface{}, error)
func PropFindValue(value interface{}) PropFindFunc {
return func(raw *RawXMLValue) (interface{}, error) {
return value, nil
}
}
func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]PropFindFunc) (*Response, error) { func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]PropFindFunc) (*Response, error) {
resp := &Response{Hrefs: []Href{Href{Path: path}}} resp := NewOKResponse(path)
if _, ok := props[ResourceTypeName]; !ok { if _, ok := props[ResourceTypeName]; !ok {
props[ResourceTypeName] = PropFindValue(NewResourceType()) props[ResourceTypeName] = func(*RawXMLValue) (interface{}, error) {
return NewResourceType(), nil
}
} }
if propfind.PropName != nil { if propfind.PropName != nil {
@ -191,8 +179,9 @@ func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]Pro
code := http.StatusOK code := http.StatusOK
if err != nil { if err != nil {
// TODO: don't throw away error message here
code = HTTPErrorFromError(err).Code code = HTTPErrorFromError(err).Code
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil) val = emptyVal
} }
if err := resp.EncodeProp(code, val); err != nil { if err := resp.EncodeProp(code, val); err != nil {
@ -213,8 +202,8 @@ func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]Pro
f, ok := props[xmlName] f, ok := props[xmlName]
if ok { if ok {
if v, err := f(&raw); err != nil { if v, err := f(&raw); err != nil {
// TODO: don't throw away error message here
code = HTTPErrorFromError(err).Code code = HTTPErrorFromError(err).Code
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
} else { } else {
code = http.StatusOK code = http.StatusOK
val = v val = v

127
server.go
View File

@ -14,14 +14,14 @@ import (
// FileSystem is a WebDAV server backend. // FileSystem is a WebDAV server backend.
type FileSystem interface { type FileSystem interface {
Open(ctx context.Context, name string) (io.ReadCloser, error) Open(name string) (io.ReadCloser, error)
Stat(ctx context.Context, name string) (*FileInfo, error) Stat(name string) (*FileInfo, error)
ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) Readdir(name string, recursive bool) ([]FileInfo, error)
Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fileInfo *FileInfo, created bool, err error) Create(name string) (io.WriteCloser, error)
RemoveAll(ctx context.Context, name string) error RemoveAll(name string) error
Mkdir(ctx context.Context, name string) error Mkdir(name string) error
Copy(ctx context.Context, name, dest string, options *CopyOptions) (created bool, err error) Copy(name, dest string, recursive, overwrite bool) (created bool, err error)
Move(ctx context.Context, name, dest string, options *MoveOptions) (created bool, err error) MoveAll(name, dest string, overwrite bool) (created bool, err error)
} }
// Handler handles WebDAV HTTP requests. It can be used to create a WebDAV // Handler handles WebDAV HTTP requests. It can be used to create a WebDAV
@ -38,14 +38,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
b := backend{h.FileSystem} b := backend{h.FileSystem}
hh := internal.Handler{Backend: &b} hh := internal.Handler{&b}
hh.ServeHTTP(w, r) hh.ServeHTTP(w, r)
} }
// NewHTTPError creates a new error that is associated with an HTTP status code // 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 // 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 // return errors that convey some semantics (e.g. 404 not found, 403 access
// denied, etc.) while also providing an (optional) arbitrary error context // denied, etc) while also providing an (optional) arbitrary error context
// (intended for humans). // (intended for humans).
func NewHTTPError(statusCode int, cause error) error { func NewHTTPError(statusCode int, cause error) error {
return &internal.HTTPError{Code: statusCode, Err: cause} return &internal.HTTPError{Code: statusCode, Err: cause}
@ -56,8 +56,8 @@ type backend struct {
} }
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path) fi, err := b.FileSystem.Stat(r.URL.Path)
if internal.IsNotFound(err) { if os.IsNotExist(err) {
return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
} else if err != nil { } else if err != nil {
return nil, nil, err return nil, nil, err
@ -79,15 +79,17 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e
} }
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error { func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path) fi, err := b.FileSystem.Stat(r.URL.Path)
if err != nil { if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
return err return err
} }
if fi.IsDir { if fi.IsDir {
return &internal.HTTPError{Code: http.StatusMethodNotAllowed} return &internal.HTTPError{Code: http.StatusMethodNotAllowed}
} }
f, err := b.FileSystem.Open(r.Context(), r.URL.Path) f, err := b.FileSystem.Open(r.URL.Path)
if err != nil { if err != nil {
return err return err
} }
@ -118,14 +120,16 @@ func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) { func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) {
// TODO: use partial error Response on error // TODO: use partial error Response on error
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path) fi, err := b.FileSystem.Stat(r.URL.Path)
if err != nil { if os.IsNotExist(err) {
return nil, &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
return nil, err return nil, err
} }
var resps []internal.Response var resps []internal.Response
if depth != internal.DepthZero && fi.IsDir { if depth != internal.DepthZero && fi.IsDir {
children, err := b.FileSystem.ReadDir(r.Context(), r.URL.Path, depth == internal.DepthInfinity) children, err := b.FileSystem.Readdir(r.URL.Path, depth == internal.DepthInfinity)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -162,26 +166,26 @@ func (b *backend) propFindFile(propfind *internal.PropFind, fi *FileInfo) (*inte
} }
if !fi.IsDir { if !fi.IsDir {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{ props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
Length: fi.Size, return &internal.GetContentLength{Length: fi.Size}, nil
}) }
if !fi.ModTime.IsZero() { if !fi.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{ props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
LastModified: internal.Time(fi.ModTime), return &internal.GetLastModified{LastModified: internal.Time(fi.ModTime)}, nil
}) }
} }
if fi.MIMEType != "" { if fi.MIMEType != "" {
props[internal.GetContentTypeName] = internal.PropFindValue(&internal.GetContentType{ props[internal.GetContentTypeName] = func(*internal.RawXMLValue) (interface{}, error) {
Type: fi.MIMEType, return &internal.GetContentType{Type: fi.MIMEType}, nil
}) }
} }
if fi.ETag != "" { if fi.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{ props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
ETag: internal.ETag(fi.ETag), return &internal.GetETag{ETag: internal.ETag(fi.ETag)}, nil
}) }
} }
} }
@ -193,59 +197,41 @@ func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*
return nil, internal.HTTPErrorf(http.StatusForbidden, "webdav: PROPPATCH is unsupported") return nil, internal.HTTPErrorf(http.StatusForbidden, "webdav: PROPPATCH is unsupported")
} }
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error { func (b *backend) Put(r *http.Request) (*internal.Href, error) {
ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match")) wc, err := b.FileSystem.Create(r.URL.Path)
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 { if err != nil {
return err return nil, err
}
defer wc.Close()
if _, err := io.Copy(wc, r.Body); err != nil {
return nil, err
} }
if fi.MIMEType != "" { return nil, wc.Close()
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 { func (b *backend) Delete(r *http.Request) error {
return b.FileSystem.RemoveAll(r.Context(), r.URL.Path) err := b.FileSystem.RemoveAll(r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
}
return err
} }
func (b *backend) Mkcol(r *http.Request) error { func (b *backend) Mkcol(r *http.Request) error {
if r.Header.Get("Content-Type") != "" { if r.Header.Get("Content-Type") != "" {
return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request") return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request")
} }
err := b.FileSystem.Mkdir(r.Context(), r.URL.Path) err := b.FileSystem.Mkdir(r.URL.Path)
if internal.IsNotFound(err) { if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusConflict, Err: err} return &internal.HTTPError{Code: http.StatusConflict, Err: err}
} }
return err return err
} }
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) { func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
options := CopyOptions{ created, err = b.FileSystem.Copy(r.URL.Path, dest.Path, recursive, overwrite)
NoRecursive: !recursive,
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Copy(r.Context(), r.URL.Path, dest.Path, &options)
if os.IsExist(err) { if os.IsExist(err) {
return false, &internal.HTTPError{http.StatusPreconditionFailed, err} return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
} }
@ -253,10 +239,7 @@ 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) { func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
options := MoveOptions{ created, err = b.FileSystem.MoveAll(r.URL.Path, dest.Path, overwrite)
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Move(r.Context(), r.URL.Path, dest.Path, &options)
if os.IsExist(err) { if os.IsExist(err) {
return false, &internal.HTTPError{http.StatusPreconditionFailed, err} return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
} }
@ -265,7 +248,7 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr
// BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a // BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a
// CardDAV addressbook-home-set. It should only be created via // CardDAV addressbook-home-set. It should only be created via
// caldav.NewCalendarHomeSet or carddav.NewAddressBookHomeSet. Only to // `caldav.NewCalendarHomeSet()` or `carddav.NewAddressbookHomeSet()`. Only to
// be used server-side, for listing a user's home sets as determined by the // be used server-side, for listing a user's home sets as determined by the
// (external) backend. // (external) backend.
type BackendSuppliedHomeSet interface { type BackendSuppliedHomeSet interface {
@ -278,10 +261,8 @@ type UserPrincipalBackend interface {
CurrentUserPrincipal(ctx context.Context) (string, error) CurrentUserPrincipal(ctx context.Context) (string, error)
} }
// Capability indicates the features that a server supports.
type Capability string type Capability string
// ServePrincipalOptions holds options for ServePrincipal.
type ServePrincipalOptions struct { type ServePrincipalOptions struct {
CurrentUserPrincipalPath string CurrentUserPrincipalPath string
HomeSets []BackendSuppliedHomeSet HomeSets []BackendSuppliedHomeSet
@ -299,7 +280,7 @@ func ServePrincipal(w http.ResponseWriter, r *http.Request, options *ServePrinci
allow := []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"} allow := []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}
w.Header().Add("DAV", strings.Join(caps, ", ")) w.Header().Add("DAV", strings.Join(caps, ", "))
w.Header().Add("Allow", strings.Join(allow, ", ")) w.Header().Add("Allow", strings.Join(allow, ", "))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusNoContent)
case "PROPFIND": case "PROPFIND":
if err := servePrincipalPropfind(w, r, options); err != nil { if err := servePrincipalPropfind(w, r, options); err != nil {
internal.ServeError(w, err) internal.ServeError(w, err)

View File

@ -5,11 +5,8 @@ package webdav
import ( import (
"time" "time"
"github.com/emersion/go-webdav/internal"
) )
// FileInfo holds information about a WebDAV file.
type FileInfo struct { type FileInfo struct {
Path string Path string
Size int64 Size int64
@ -18,46 +15,3 @@ type FileInfo struct {
MIMEType string MIMEType string
ETag 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
}