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
.glide/
.idea/

View File

@ -1,6 +1,7 @@
# 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].

View File

@ -2,7 +2,6 @@ package caldav
import (
"bytes"
"context"
"fmt"
"mime"
"net/http"
@ -16,12 +15,6 @@ import (
"github.com/emersion/go-webdav/internal"
)
// DiscoverContextURL performs a DNS-based CardDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "caldavs", domain)
}
// Client provides access to a remote CardDAV server.
type Client struct {
*webdav.Client
@ -41,9 +34,9 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
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)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind)
resp, err := c.ic.PropFindFlat(principal, propfind)
if err != nil {
return "", err
}
@ -56,7 +49,7 @@ func (c *Client) FindCalendarHomeSet(ctx context.Context, principal string) (str
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(
internal.ResourceTypeName,
internal.DisplayNameName,
@ -64,7 +57,7 @@ func (c *Client) FindCalendars(ctx context.Context, calendarHomeSet string) ([]C
maxResourceSizeName,
supportedCalendarComponentSetName,
)
ms, err := c.ic.PropFind(ctx, calendarHomeSet, internal.DepthOne, propfind)
ms, err := c.ic.PropFind(calendarHomeSet, internal.DepthOne, propfind)
if err != nil {
return nil, err
}
@ -221,7 +214,7 @@ func decodeCalendarObjectList(ms *internal.MultiStatus) ([]CalendarObject, error
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)
if err != nil {
return nil, err
@ -235,7 +228,7 @@ func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *Cale
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
ms, err := c.ic.DoMultiStatus(req)
if err != nil {
return nil, err
}
@ -243,7 +236,7 @@ func (c *Client) QueryCalendar(ctx context.Context, calendar string, query *Cale
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)
if err != nil {
return nil, err
@ -251,7 +244,7 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
calendarMultiget := calendarMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 {
if multiGet == nil || len(multiGet.Paths) == 0 {
href := internal.Href{Path: path}
calendarMultiget.Hrefs = []internal.Href{href}
} else {
@ -267,7 +260,7 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
}
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
ms, err := c.ic.DoMultiStatus(req)
if err != nil {
return nil, err
}
@ -275,29 +268,29 @@ func (c *Client) MultiGetCalendar(ctx context.Context, path string, multiGet *Ca
return decodeCalendarObjectList(ms)
}
func populateCalendarObject(co *CalendarObject, h http.Header) error {
if loc := h.Get("Location"); loc != "" {
func populateCalendarObject(co *CalendarObject, resp *http.Response) error {
if loc := resp.Header.Get("Location"); loc != "" {
u, err := url.Parse(loc)
if err != nil {
return err
}
co.Path = u.Path
}
if etag := h.Get("ETag"); etag != "" {
if etag := resp.Header.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag)
if err != nil {
return err
}
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)
if err != nil {
return err
}
co.ContentLength = n
}
if lastModified := h.Get("Last-Modified"); lastModified != "" {
if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified)
if err != nil {
return err
@ -308,14 +301,14 @@ func populateCalendarObject(co *CalendarObject, h http.Header) error {
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)
if err != nil {
return nil, err
}
req.Header.Set("Accept", ical.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
resp, err := c.ic.Do(req)
if err != nil {
return nil, err
}
@ -338,13 +331,13 @@ func (c *Client) GetCalendarObject(ctx context.Context, path string) (*CalendarO
Path: resp.Request.URL.Path,
Data: cal,
}
if err := populateCalendarObject(co, resp.Header); err != nil {
if err := populateCalendarObject(co, resp); err != nil {
return nil, err
}
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: 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)
resp, err := c.ic.Do(req.WithContext(ctx))
resp, err := c.ic.Do(req)
if err != nil {
return nil, err
}
resp.Body.Close()
co := &CalendarObject{Path: path}
if err := populateCalendarObject(co, resp.Header); err != nil {
if err := populateCalendarObject(co, resp); err != nil {
return nil, err
}
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)
}
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
}
// TODO handle "infinity" values in query
// TODO handle more than just events
if comp.Name != ical.CompEvent {
return false, nil
@ -154,15 +155,15 @@ func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error
}
// Event starts in time range
if eventStart.After(start) && (end.IsZero() || eventStart.Before(end)) {
if eventStart.After(start) && eventStart.Before(end) {
return true, nil
}
// Event ends in time range
if eventEnd.After(start) && (end.IsZero() || eventEnd.Before(end)) {
if eventEnd.After(start) && eventEnd.Before(end) {
return true, nil
}
// 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 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) {
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
// TODO handle "infinity" values in query
ptime, err := field.DateTime(start.Location())
if err != nil {
return false, err
}
if ptime.After(start) && (end.IsZero() || ptime.Before(end)) {
if ptime.After(start) && ptime.Before(end) {
return true, nil
}
return false, nil

View File

@ -209,23 +209,6 @@ END:VCALENDAR`)
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2, event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.1
name: "events in open time range (no end date)",
query: &CalendarQuery{
CompFilter: CompFilter{
Name: "VCALENDAR",
Comps: []CompFilter{
CompFilter{
Name: "VEVENT",
Start: toDate(t, "20060104T000000Z"),
},
},
},
},
addrs: []CalendarObject{event1, event2, event3, todo1},
want: []CalendarObject{event2, event3},
},
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
name: "events by UID",

View File

@ -17,6 +17,8 @@ import (
"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
type PutCalendarObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite
@ -30,15 +32,11 @@ type PutCalendarObjectOptions struct {
// Backend is a CalDAV server backend.
type Backend interface {
CalendarHomeSetPath(ctx context.Context) (string, error)
CreateCalendar(ctx context.Context, calendar *Calendar) error
ListCalendars(ctx context.Context) ([]Calendar, error)
GetCalendar(ctx context.Context, path string) (*Calendar, error)
Calendar(ctx context.Context) (*Calendar, error)
GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error)
QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error)
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error)
ListCalendarObjects(ctx context.Context, req *CalendarCompRequest) ([]CalendarObject, error)
QueryCalendarObjects(ctx context.Context, query *CalendarQuery) ([]CalendarObject, error)
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (loc string, err error)
DeleteCalendarObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend
@ -78,7 +76,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
hh := internal.Handler{Backend: &b}
hh := internal.Handler{&b}
hh.ServeHTTP(w, r)
}
@ -216,7 +214,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *cal
}
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 {
return err
}
@ -373,12 +371,6 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
var resps []internal.Response
switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
@ -426,21 +418,24 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
}
}
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 {
return nil, err
}
resp, err := b.propFindCalendar(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
if r.URL.Path == ab.Path {
resp, err := b.propFindCalendar(r.Context(), propfind, ab)
if err != nil {
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:
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
}
func (b *backend) propFindRoot(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
@ -484,13 +464,15 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
calendarHomeSetName: internal.PropFindValue(&calendarHomeSet{
Href: internal.Href{Path: homeSetPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
calendarHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
return &calendarHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
},
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
}
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?
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)),
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
}
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
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, calendarName)),
calendarDescriptionName: internal.PropFindValue(&calendarDescription{
Description: cal.Description,
}),
supportedCalendarDataName: internal.PropFindValue(&supportedCalendarData{
Types: []calendarDataType{
{ContentType: ical.MIMEType, Version: "2.0"},
},
}),
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName, calendarName), nil
},
internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.DisplayName{Name: cal.Name}, nil
},
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) {
components := []comp{}
if cal.SupportedComponentSet != nil {
for _, name := range cal.SupportedComponentSet {
components = append(components, comp{Name: name})
}
} else {
components = append(components, comp{Name: ical.CompEvent})
}
return &supportedCalendarComponentSet{
Comp: components,
Comp: []comp{
{Name: ical.CompEvent},
},
}, nil
},
}
if cal.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: cal.Name,
})
}
if cal.Description != "" {
props[calendarDescriptionName] = internal.PropFindValue(&calendarDescription{
Description: cal.Description,
})
props[calendarDescriptionName] = func(*internal.RawXMLValue) (interface{}, error) {
return &calendarDescription{Description: cal.Description}, nil
}
}
if cal.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{
Size: cal.MaxResourceSize,
})
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &maxResourceSize{Size: cal.MaxResourceSize}, 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
@ -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) {
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 {
return nil, err
}
abs := []*Calendar{ab}
var resps []internal.Response
for _, ab := range abs {
resp, err := b.propFindCalendar(ctx, propfind, &ab)
resp, err := b.propFindCalendar(ctx, propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if recurse {
resps_, err := b.propFindAllCalendarObjects(ctx, propfind, &ab)
resps_, err := b.propFindAllCalendarObjects(ctx, propfind, ab)
if err != nil {
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
},
internal.GetContentTypeName: internal.PropFindValue(&internal.GetContentType{
Type: ical.MIMEType,
}),
internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentType{Type: ical.MIMEType}, nil
},
// TODO: calendar-data can only be used in REPORT requests
calendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer
@ -620,20 +600,20 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
}
if co.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: co.ContentLength,
})
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentLength{Length: co.ContentLength}, nil
}
}
if !co.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(co.ModTime),
})
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetLastModified{LastModified: internal.Time(co.ModTime)}, nil
}
}
if co.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(co.ETag),
})
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetETag{ETag: internal.ETag(co.ETag)}, nil
}
}
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) {
var dataReq CalendarCompRequest
aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq)
aos, err := b.Backend.ListCalendarObjects(ctx, &dataReq)
if err != nil {
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) {
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"))
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"))
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 {
// 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
cal, err := ical.NewDecoder(r.Body).Decode()
if err != nil {
// TODO: send CALDAV:valid-calendar-data error
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
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 {
return err
return nil, err
}
if co.ETag != "" {
w.Header().Set("ETag", internal.ETag(co.ETag).String())
}
if !co.ModTime.IsZero() {
w.Header().Set("Last-Modified", co.ModTime.UTC().Format(http.TimeFormat))
}
if co.Path != "" {
w.Header().Set("Location", co.Path)
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
return nil
return &internal.Href{Path: loc}, nil
}
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 {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar {
return internal.HTTPErrorf(http.StatusForbidden, "caldav: calendar creation not allowed at given location")
}
cal := Calendar{
Path: r.URL.Path,
}
if !internal.IsRequestBodyEmpty(r) {
var m mkcolReq
if err := internal.DecodeXMLRequest(r, &m); err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: error parsing mkcol request: %s", err.Error())
}
if !m.ResourceType.Is(internal.CollectionName) || !m.ResourceType.Is(calendarName) {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
cal.Name = m.DisplayName
// TODO ...
}
return b.Backend.CreateCalendar(r.Context(), &cal)
panic("TODO")
}
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) {
return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Move not implemented")
panic("TODO")
}
// https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
@ -762,7 +708,7 @@ const (
)
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)
return &internal.HTTPError{
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"
)
type testBackend struct {
addressBooks []AddressBook
}
type testBackend struct{}
type contextKey string
@ -39,46 +37,22 @@ func (*testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return r, nil
}
func (*testBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
func (*testBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) {
r := ctx.Value(homeSetPathKey).(string)
return r, nil
}
func (*testBackend) ListAddressBooks(ctx context.Context) ([]AddressBook, error) {
func (*testBackend) AddressBook(ctx context.Context) (*AddressBook, error) {
p := ctx.Value(addressBookPathKey).(string)
return []AddressBook{
AddressBook{
Path: p,
Name: "My contacts",
Description: "Default address book",
MaxResourceSize: 1024,
SupportedAddressData: nil,
},
return &AddressBook{
Path: p,
Name: "My contacts",
Description: "Default address book",
MaxResourceSize: 1024,
SupportedAddressData: nil,
}, nil
}
func (b *testBackend) GetAddressBook(ctx context.Context, path string) (*AddressBook, error) {
abs, err := b.ListAddressBooks(ctx)
if err != nil {
panic(err)
}
for _, ab := range abs {
if ab.Path == path {
return &ab, nil
}
}
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
func (b *testBackend) CreateAddressBook(ctx context.Context, ab *AddressBook) error {
b.addressBooks = append(b.addressBooks, *ab)
return nil
}
func (*testBackend) DeleteAddressBook(ctx context.Context, path string) error {
panic("TODO: implement")
}
func (*testBackend) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error) {
if path == alicePath {
card, err := vcard.NewDecoder(strings.NewReader(aliceData)).Decode()
@ -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) {
p := ctx.Value(addressBookPathKey).(string)
if !strings.HasPrefix(path, p) {
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
func (b *testBackend) ListAddressObjects(ctx context.Context, req *AddressDataRequest) ([]AddressObject, error) {
alice, err := b.GetAddressObject(ctx, alicePath, req)
if err != nil {
return nil, err
@ -107,11 +77,11 @@ func (b *testBackend) ListAddressObjects(ctx context.Context, path string, req *
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")
}
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")
}
@ -143,7 +113,6 @@ func TestAddressBookDiscovery(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
h := Handler{&testBackend{}, tc.prefix}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -166,21 +135,21 @@ func TestAddressBookDiscovery(t *testing.T) {
if err != nil {
t.Fatalf("error creating client: %s", err)
}
cup, err := client.FindCurrentUserPrincipal(ctx)
cup, err := client.FindCurrentUserPrincipal()
if err != nil {
t.Fatalf("error finding user principal url: %s", err)
}
if cup != tc.currentUserPrincipal {
t.Fatalf("Found current user principal URL '%s', expected '%s'", cup, tc.currentUserPrincipal)
}
hsp, err := client.FindAddressBookHomeSet(ctx, cup)
hsp, err := client.FindAddressBookHomeSet(cup)
if err != nil {
t.Fatalf("error finding home set path: %s", err)
}
if hsp != tc.homeSetPath {
t.Fatalf("Found home set path '%s', expected '%s'", hsp, tc.homeSetPath)
}
abs, err := client.FindAddressBooks(ctx, hsp)
abs, err := client.FindAddressBooks(hsp)
if err != nil {
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 (
"bytes"
"context"
"fmt"
"mime"
"net"
"net/http"
"net/url"
"strconv"
@ -16,10 +16,38 @@ import (
"github.com/emersion/go-webdav/internal"
)
// DiscoverContextURL performs a DNS-based CardDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "carddavs", domain)
// Discover performs a DNS-based CardDAV service discovery as described in
// RFC 6352 section 11. It returns the URL to the CardDAV server.
func Discover(domain string) (string, error) {
// Only lookup carddavs (not carddav), plaintext connections are insecure
_, addrs, err := net.LookupSRV("carddavs", "tcp", domain)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsTemporary {
return "", err
}
} else if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("carddav: domain doesn't have an SRV record")
}
addr := addrs[0]
target := strings.TrimSuffix(addr.Target, ".")
if target == "" {
return "", fmt.Errorf("carddav: empty target in SRV record")
}
// 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.
@ -41,8 +69,8 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
return &Client{wc, ic}, nil
}
func (c *Client) HasSupport(ctx context.Context) error {
classes, _, err := c.ic.Options(ctx, "")
func (c *Client) HasSupport() error {
classes, _, err := c.ic.Options("")
if err != nil {
return err
}
@ -53,9 +81,9 @@ func (c *Client) HasSupport(ctx context.Context) error {
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)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind)
resp, err := c.ic.PropFindFlat(principal, propfind)
if err != nil {
return "", err
}
@ -76,7 +104,7 @@ func decodeSupportedAddressData(supported *supportedAddressData) []AddressDataTy
return l
}
func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string) ([]AddressBook, error) {
func (c *Client) FindAddressBooks(addressBookHomeSet string) ([]AddressBook, error) {
propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName,
internal.DisplayNameName,
@ -84,7 +112,7 @@ func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string
maxResourceSizeName,
supportedAddressDataName,
)
ms, err := c.ic.PropFind(ctx, addressBookHomeSet, internal.DepthOne, propfind)
ms, err := c.ic.PropFind(addressBookHomeSet, internal.DepthOne, propfind)
if err != nil {
return nil, err
}
@ -243,7 +271,7 @@ func decodeAddressList(ms *internal.MultiStatus) ([]AddressObject, error) {
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)
if err != nil {
return nil, err
@ -269,7 +297,7 @@ func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
ms, err := c.ic.DoMultiStatus(req)
if err != nil {
return nil, err
}
@ -277,7 +305,7 @@ func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query
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)
if err != nil {
return nil, err
@ -285,7 +313,7 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
addressbookMultiget := addressbookMultiget{Prop: propReq}
if len(multiGet.Paths) == 0 {
if multiGet == nil || len(multiGet.Paths) == 0 {
href := internal.Href{Path: path}
addressbookMultiget.Hrefs = []internal.Href{href}
} else {
@ -302,7 +330,7 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
ms, err := c.ic.DoMultiStatus(req)
if err != nil {
return nil, err
}
@ -310,29 +338,29 @@ func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet
return decodeAddressList(ms)
}
func populateAddressObject(ao *AddressObject, h http.Header) error {
if loc := h.Get("Location"); loc != "" {
func populateAddressObject(ao *AddressObject, resp *http.Response) error {
if loc := resp.Header.Get("Location"); loc != "" {
u, err := url.Parse(loc)
if err != nil {
return err
}
ao.Path = u.Path
}
if etag := h.Get("ETag"); etag != "" {
if etag := resp.Header.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag)
if err != nil {
return err
}
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)
if err != nil {
return err
}
ao.ContentLength = n
}
if lastModified := h.Get("Last-Modified"); lastModified != "" {
if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified)
if err != nil {
return err
@ -343,14 +371,14 @@ func populateAddressObject(ao *AddressObject, h http.Header) error {
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)
if err != nil {
return nil, err
}
req.Header.Set("Accept", vcard.MIMEType)
resp, err := c.ic.Do(req.WithContext(ctx))
resp, err := c.ic.Do(req)
if err != nil {
return nil, err
}
@ -373,13 +401,13 @@ func (c *Client) GetAddressObject(ctx context.Context, path string) (*AddressObj
Path: resp.Request.URL.Path,
Card: card,
}
if err := populateAddressObject(ao, resp.Header); err != nil {
if err := populateAddressObject(ao, resp); err != nil {
return nil, err
}
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: 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)
resp, err := c.ic.Do(req.WithContext(ctx))
resp, err := c.ic.Do(req)
if err != nil {
return nil, err
}
resp.Body.Close()
ao := &AddressObject{Path: path}
if err := populateAddressObject(ao, resp.Header); err != nil {
if err := populateAddressObject(ao, resp); err != nil {
return nil, err
}
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
// 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
if query.Limit > 0 {
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
}
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 {
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)
}
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"
)
// TODO: add support for multiple address books
type PutAddressObjectOptions struct {
// IfNoneMatch indicates that the client does not want to overwrite
// an existing resource.
@ -27,15 +29,12 @@ type PutAddressObjectOptions struct {
// Backend is a CardDAV server backend.
type Backend interface {
AddressBookHomeSetPath(ctx context.Context) (string, error)
ListAddressBooks(ctx context.Context) ([]AddressBook, error)
GetAddressBook(ctx context.Context, path string) (*AddressBook, error)
CreateAddressBook(ctx context.Context, addressBook *AddressBook) error
DeleteAddressBook(ctx context.Context, path string) error
AddressbookHomeSetPath(ctx context.Context) (string, error)
AddressBook(ctx context.Context) (*AddressBook, error)
GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error)
ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error)
QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error)
PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, error)
ListAddressObjects(ctx context.Context, req *AddressDataRequest) ([]AddressObject, error)
QueryAddressObjects(ctx context.Context, query *AddressBookQuery) ([]AddressObject, error)
PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (loc string, err error)
DeleteAddressObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend
@ -75,7 +74,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
hh := internal.Handler{Backend: &b}
hh := internal.Handler{&b}
hh.ServeHTTP(w, r)
}
@ -91,7 +90,7 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) error {
}
if report.Query != nil {
return h.handleQuery(r, w, report.Query)
return h.handleQuery(r.Context(), w, report.Query)
} else if report.Multiget != nil {
return h.handleMultiget(r.Context(), w, report.Multiget)
}
@ -153,7 +152,7 @@ func decodeAddressDataReq(addressData *addressDataReq) (*AddressDataRequest, err
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
if query.Prop != nil {
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 {
pf, err := decodePropFilter(&el)
if err != nil {
return &internal.HTTPError{Code: http.StatusBadRequest, Err: err}
return &internal.HTTPError{http.StatusBadRequest, err}
}
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 {
return err
}
@ -197,7 +196,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *add
AllProp: query.AllProp,
PropName: query.PropName,
}
resp, err := b.propFindAddressObject(r.Context(), &propfind, &ao)
resp, err := b.propFindAddressObject(ctx, &propfind, &ao)
if err != nil {
return err
}
@ -339,12 +338,6 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
var resps []internal.Response
switch resType {
case resourceTypeRoot:
resp, err := b.propFindRoot(r.Context(), propfind)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
case resourceTypeUserPrincipal:
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
if err != nil {
@ -372,7 +365,7 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
}
}
case resourceTypeAddressBookHomeSet:
homeSetPath, err := b.Backend.AddressBookHomeSetPath(r.Context())
homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context())
if err != nil {
return nil, err
}
@ -392,21 +385,24 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
}
}
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 {
return nil, err
}
resp, err := b.propFindAddressBook(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllAddressObjects(r.Context(), propfind, ab)
if r.URL.Path == ab.Path {
resp, err := b.propFindAddressBook(r.Context(), propfind, ab)
if err != nil {
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:
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
}
func (b *backend) propFindRoot(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
homeSetPath, err := b.Backend.AddressbookHomeSetPath(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.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
addressBookHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(ctx)
if err != nil {
return nil, err
}
return &addressbookHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFind) (*internal.Response, error) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(ctx)
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
homeSetPath, err := b.Backend.AddressbookHomeSetPath(ctx)
if err != nil {
return nil, err
}
@ -470,13 +457,11 @@ func (b *backend) propFindHomeSet(ctx context.Context, propfind *internal.PropFi
// TODO anything else to return here?
props := map[xml.Name]internal.PropFindFunc{
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
if err != nil {
return nil, err
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
}
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
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, addressBookName)),
supportedAddressDataName: internal.PropFindValue(&supportedAddressData{
Types: []addressDataType{
{ContentType: vcard.MIMEType, Version: "3.0"},
{ContentType: vcard.MIMEType, Version: "4.0"},
},
}),
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName, addressBookName), nil
},
internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.DisplayName{Name: ab.Name}, nil
},
addressBookDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
return &addressbookDescription{Description: ab.Description}, nil
},
supportedAddressDataName: func(*internal.RawXMLValue) (interface{}, error) {
return &supportedAddressData{
Types: []addressDataType{
{ContentType: vcard.MIMEType, Version: "3.0"},
{ContentType: vcard.MIMEType, Version: "4.0"},
},
}, nil
},
}
if ab.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: ab.Name,
})
}
if ab.Description != "" {
props[addressBookDescriptionName] = internal.PropFindValue(&addressbookDescription{
Description: ab.Description,
})
}
if ab.MaxResourceSize > 0 {
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{
Size: ab.MaxResourceSize,
})
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &maxResourceSize{Size: ab.MaxResourceSize}, nil
}
}
return internal.NewPropFindResponse(ab.Path, propfind, props)
}
func (b *backend) propFindAllAddressBooks(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) {
abs, err := b.Backend.ListAddressBooks(ctx)
// TODO iterate over all address books once having multiple is supported
ab, err := b.Backend.AddressBook(ctx)
if err != nil {
return nil, err
}
abs := []*AddressBook{ab}
var resps []internal.Response
for _, ab := range abs {
resp, err := b.propFindAddressBook(ctx, propfind, &ab)
resp, err := b.propFindAddressBook(ctx, propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if recurse {
resps_, err := b.propFindAllAddressObjects(ctx, propfind, &ab)
resps_, err := b.propFindAllAddressObjects(ctx, propfind, ab)
if err != nil {
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
},
internal.GetContentTypeName: internal.PropFindValue(&internal.GetContentType{
Type: vcard.MIMEType,
}),
internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentType{Type: vcard.MIMEType}, nil
},
// TODO: address-data can only be used in REPORT requests
addressDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer
@ -569,20 +553,20 @@ func (b *backend) propFindAddressObject(ctx context.Context, propfind *internal.
}
if ao.ContentLength > 0 {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: ao.ContentLength,
})
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentLength{Length: ao.ContentLength}, nil
}
}
if !ao.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(ao.ModTime),
})
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetLastModified{LastModified: internal.Time(ao.ModTime)}, nil
}
}
if ao.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(ao.ETag),
})
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetETag{ETag: internal.ETag(ao.ETag)}, nil
}
}
return internal.NewPropFindResponse(ao.Path, propfind, props)
@ -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) {
var dataReq AddressDataRequest
aos, err := b.Backend.ListAddressObjects(ctx, ab.Path, &dataReq)
aos, err := b.Backend.ListAddressObjects(ctx, &dataReq)
if err != nil {
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) {
homeSetPath, err := b.Backend.AddressBookHomeSetPath(r.Context())
homeSetPath, err := b.Backend.AddressbookHomeSetPath(r.Context())
if err != nil {
return nil, err
}
@ -646,7 +630,7 @@ func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*
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"))
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"))
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 {
// 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
card, err := vcard.NewDecoder(r.Body).Decode()
if err != nil {
// TODO: send CARDDAV:valid-address-data error
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
return nil, internal.HTTPErrorf(http.StatusBadRequest, "carddav: failed to parse vCard: %v", err)
}
// TODO: add support for the CARDDAV:no-uid-conflict error
ao, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
loc, err := b.Backend.PutAddressObject(r.Context(), r.URL.Path, card, &opts)
if err != nil {
return err
}
if ao.ETag != "" {
w.Header().Set("ETag", internal.ETag(ao.ETag).String())
}
if !ao.ModTime.IsZero() {
w.Header().Set("Last-Modified", ao.ModTime.UTC().Format(http.TimeFormat))
}
if ao.Path != "" {
w.Header().Set("Location", ao.Path)
return nil, err
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
return nil
return &internal.Href{Path: loc}, nil
}
func (b *backend) Delete(r *http.Request) error {
switch b.resourceTypeAtPath(r.URL.Path) {
case resourceTypeAddressBook:
return b.Backend.DeleteAddressBook(r.Context(), r.URL.Path)
case resourceTypeAddressObject:
return b.Backend.DeleteAddressObject(r.Context(), r.URL.Path)
}
return internal.HTTPErrorf(http.StatusForbidden, "carddav: cannot delete resource at given location")
return b.Backend.DeleteAddressObject(r.Context(), r.URL.Path)
}
func (b *backend) Mkcol(r *http.Request) error {
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeAddressBook {
return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation not allowed at given location")
}
ab := AddressBook{
Path: r.URL.Path,
}
if !internal.IsRequestBodyEmpty(r) {
var m mkcolReq
if err := internal.DecodeXMLRequest(r, &m); err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: error parsing mkcol request: %s", err.Error())
}
if !m.ResourceType.Is(internal.CollectionName) || !m.ResourceType.Is(addressBookName) {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
ab.Name = m.DisplayName
ab.Description = m.Description.Description
// TODO ...
}
return b.Backend.CreateAddressBook(r.Context(), &ab)
return internal.HTTPErrorf(http.StatusForbidden, "carddav: address book creation unsupported")
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
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) {
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
const (
@ -747,7 +691,7 @@ const (
)
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)
return &internal.HTTPError{
Code: 409,

View File

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

View File

@ -30,3 +30,24 @@ type groupMembership struct {
XMLName xml.Name `xml:"DAV: group-membership"`
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
import (
"context"
"fmt"
"io"
"mime"
@ -14,11 +13,8 @@ import (
"github.com/emersion/go-webdav/internal"
)
// LocalFileSystem implements FileSystem for a local directory.
type LocalFileSystem string
var _ FileSystem = LocalFileSystem("")
func (fs LocalFileSystem) localPath(name string) (string, error) {
if (filepath.Separator != '/' && strings.IndexRune(name, filepath.Separator) >= 0) || strings.Contains(name, "\x00") {
return "", internal.HTTPErrorf(http.StatusBadRequest, "webdav: invalid character in path")
@ -38,7 +34,7 @@ func (fs LocalFileSystem) externalPath(name string) (string, error) {
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)
if err != nil {
return nil, err
@ -63,31 +59,19 @@ func fileInfoFromOS(p string, fi os.FileInfo) *FileInfo {
}
}
func errFromOS(err error) error {
if os.IsNotExist(err) {
return NewHTTPError(http.StatusNotFound, err)
} else if os.IsPermission(err) {
return NewHTTPError(http.StatusForbidden, err)
} else if os.IsTimeout(err) {
return NewHTTPError(http.StatusServiceUnavailable, err)
} else {
return err
}
}
func (fs LocalFileSystem) Stat(ctx context.Context, name string) (*FileInfo, error) {
func (fs LocalFileSystem) Stat(name string) (*FileInfo, error) {
p, err := fs.localPath(name)
if err != nil {
return nil, err
}
fi, err := os.Stat(p)
if err != nil {
return nil, errFromOS(err)
return nil, err
}
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)
if err != nil {
return nil, err
@ -111,60 +95,18 @@ func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bo
}
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)
if err != nil {
return nil, false, err
return nil, err
}
fi, _ = fs.Stat(ctx, name)
created = fi == nil
etag := ""
if fi != nil {
etag = fi.ETag
}
if opts.IfMatch.IsSet() {
if ok, err := opts.IfMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if !ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-Match condition failed"))
}
}
if opts.IfNoneMatch.IsSet() {
if ok, err := opts.IfNoneMatch.MatchETag(etag); err != nil {
return nil, false, NewHTTPError(http.StatusBadRequest, err)
} else if ok {
return nil, false, NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("If-None-Match condition failed"))
}
}
wc, err := os.Create(p)
if err != nil {
return nil, false, errFromOS(err)
}
defer wc.Close()
if _, err := io.Copy(wc, body); err != nil {
os.Remove(p)
return nil, false, err
}
if err := wc.Close(); err != nil {
os.Remove(p)
return nil, false, err
}
fi, err = fs.Stat(ctx, name)
if err != nil {
return nil, false, err
}
return fi, created, err
return os.Create(p)
}
func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error {
func (fs LocalFileSystem) RemoveAll(name string) error {
p, err := fs.localPath(name)
if err != nil {
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
// case the resource doesn't exist. We need to Stat before RemoveAll.
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)
if err != nil {
return err
}
return errFromOS(os.Mkdir(p, 0755))
return os.Mkdir(p, 0755)
}
func copyRegularFile(src, dst string, perm os.FileMode) error {
srcFile, err := os.Open(src)
if err != nil {
return errFromOS(err)
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if os.IsNotExist(err) {
return NewHTTPError(http.StatusConflict, err)
} else if err != nil {
return errFromOS(err)
if err != nil {
// TODO: send http.StatusConflict on os.IsNotExist
return err
}
defer dstFile.Close()
@ -209,7 +150,7 @@ func copyRegularFile(src, dst string, perm os.FileMode) error {
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)
if err != nil {
return false, err
@ -224,21 +165,21 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
srcInfo, err := os.Stat(srcPath)
if err != nil {
return false, errFromOS(err)
return false, err
}
srcPerm := srcInfo.Mode() & os.ModePerm
if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) {
return false, errFromOS(err)
return false, err
}
created = true
} else {
if options.NoOverwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
if !overwrite {
return false, os.ErrExist
}
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 err := os.Mkdir(dstPath, srcPerm); err != nil {
return errFromOS(err)
return err
}
} else {
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 nil
})
if err != nil {
return false, errFromOS(err)
return false, err
}
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)
if err != nil {
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 !os.IsNotExist(err) {
return false, errFromOS(err)
return false, err
}
created = true
} else {
if options.NoOverwrite {
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
if !overwrite {
return false, os.ErrExist
}
if err := os.RemoveAll(dstPath); err != nil {
return false, errFromOS(err)
return false, err
}
}
if err := os.Rename(srcPath, dstPath); err != nil {
return false, errFromOS(err)
return false, err
}
return created, nil
}
var _ FileSystem = LocalFileSystem("")

4
go.mod
View File

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

View File

@ -2,12 +2,10 @@ package internal
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"path"
@ -15,42 +13,6 @@ import (
"unicode"
)
// DiscoverContextURL performs a DNS-based CardDAV/CalDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, service, domain string) (string, error) {
var resolver net.Resolver
// Only lookup TLS records, plaintext connections are insecure
_, addrs, err := resolver.LookupSRV(ctx, service+"s", "tcp", domain)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsTemporary {
return "", err
}
} else if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("webdav: domain doesn't have an SRV record")
}
addr := addrs[0]
target := strings.TrimSuffix(addr.Target, ".")
if target == "" {
return "", fmt.Errorf("webdav: empty target in SRV record")
}
// TODO: perform a TXT lookup, check for a "path" key in the response
u := url.URL{Scheme: "https"}
if addr.Port == 443 {
u.Host = target
} else {
u.Host = fmt.Sprintf("%v:%v", target, addr.Port)
}
u.Path = "/.well-known/" + service
return u.String(), nil
}
// HTTPClient performs HTTP requests. It's implemented by *http.Client.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
@ -169,7 +131,7 @@ func (c *Client) DoMultiStatus(req *http.Request) (*MultiStatus, error) {
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)
if err != nil {
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())
return c.DoMultiStatus(req.WithContext(ctx))
return c.DoMultiStatus(req)
}
// PropfindFlat performs a PROPFIND request with a zero depth.
func (c *Client) PropFindFlat(ctx context.Context, path string, propfind *PropFind) (*Response, error) {
ms, err := c.PropFind(ctx, path, DepthZero, propfind)
func (c *Client) PropFindFlat(path string, propfind *PropFind) (*Response, error) {
ms, err := c.PropFind(path, DepthZero, propfind)
if err != nil {
return nil, err
}
@ -212,13 +174,13 @@ func parseCommaSeparatedSet(values []string, upper bool) map[string]bool {
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)
if err != nil {
return nil, nil, err
}
resp, err := c.Do(req.WithContext(ctx))
resp, err := c.Do(req)
if err != nil {
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
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{
SyncToken: syncToken,
SyncLevel: level.String(),
@ -247,7 +209,7 @@ func (c *Client) SyncCollection(ctx context.Context, path, syncToken string, lev
return nil, err
}
ms, err := c.DoMultiStatus(req.WithContext(ctx))
ms, err := c.DoMultiStatus(req)
if err != nil {
return nil, err
}

View File

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

View File

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

127
server.go
View File

@ -14,14 +14,14 @@ import (
// FileSystem is a WebDAV server backend.
type FileSystem interface {
Open(ctx context.Context, name string) (io.ReadCloser, error)
Stat(ctx context.Context, name string) (*FileInfo, error)
ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error)
Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fileInfo *FileInfo, created bool, err error)
RemoveAll(ctx context.Context, name string) error
Mkdir(ctx context.Context, name string) error
Copy(ctx context.Context, name, dest string, options *CopyOptions) (created bool, err error)
Move(ctx context.Context, name, dest string, options *MoveOptions) (created bool, err error)
Open(name string) (io.ReadCloser, error)
Stat(name string) (*FileInfo, error)
Readdir(name string, recursive bool) ([]FileInfo, error)
Create(name string) (io.WriteCloser, error)
RemoveAll(name string) error
Mkdir(name string) error
Copy(name, dest string, recursive, overwrite bool) (created bool, err error)
MoveAll(name, dest string, overwrite bool) (created bool, err error)
}
// 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}
hh := internal.Handler{Backend: &b}
hh := internal.Handler{&b}
hh.ServeHTTP(w, r)
}
// NewHTTPError creates a new error that is associated with an HTTP status code
// and optionally an error that lead to it. Backends can use this functions to
// return errors that convey some semantics (e.g. 404 not found, 403 access
// denied, etc.) while also providing an (optional) arbitrary error context
// denied, etc) while also providing an (optional) arbitrary error context
// (intended for humans).
func NewHTTPError(statusCode int, cause error) error {
return &internal.HTTPError{Code: statusCode, Err: cause}
@ -56,8 +56,8 @@ type backend struct {
}
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if internal.IsNotFound(err) {
fi, err := b.FileSystem.Stat(r.URL.Path)
if os.IsNotExist(err) {
return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
} else if err != nil {
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 {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if err != nil {
fi, err := b.FileSystem.Stat(r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
return err
}
if fi.IsDir {
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 {
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) {
// TODO: use partial error Response on error
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if err != nil {
fi, err := b.FileSystem.Stat(r.URL.Path)
if os.IsNotExist(err) {
return nil, &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
return nil, err
}
var resps []internal.Response
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 {
return nil, err
}
@ -162,26 +166,26 @@ func (b *backend) propFindFile(propfind *internal.PropFind, fi *FileInfo) (*inte
}
if !fi.IsDir {
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: fi.Size,
})
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentLength{Length: fi.Size}, nil
}
if !fi.ModTime.IsZero() {
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(fi.ModTime),
})
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetLastModified{LastModified: internal.Time(fi.ModTime)}, nil
}
}
if fi.MIMEType != "" {
props[internal.GetContentTypeName] = internal.PropFindValue(&internal.GetContentType{
Type: fi.MIMEType,
})
props[internal.GetContentTypeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentType{Type: fi.MIMEType}, nil
}
}
if fi.ETag != "" {
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(fi.ETag),
})
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
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")
}
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := ConditionalMatch(r.Header.Get("If-Match"))
opts := CreateOptions{
IfNoneMatch: ifNoneMatch,
IfMatch: ifMatch,
}
fi, created, err := b.FileSystem.Create(r.Context(), r.URL.Path, r.Body, &opts)
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
wc, err := b.FileSystem.Create(r.URL.Path)
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 != "" {
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
return nil, wc.Close()
}
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 {
if r.Header.Get("Content-Type") != "" {
return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request")
}
err := b.FileSystem.Mkdir(r.Context(), r.URL.Path)
if internal.IsNotFound(err) {
err := b.FileSystem.Mkdir(r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusConflict, Err: err}
}
return err
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
options := CopyOptions{
NoRecursive: !recursive,
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Copy(r.Context(), r.URL.Path, dest.Path, &options)
created, err = b.FileSystem.Copy(r.URL.Path, dest.Path, recursive, overwrite)
if os.IsExist(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) {
options := MoveOptions{
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Move(r.Context(), r.URL.Path, dest.Path, &options)
created, err = b.FileSystem.MoveAll(r.URL.Path, dest.Path, overwrite)
if os.IsExist(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
// 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
// (external) backend.
type BackendSuppliedHomeSet interface {
@ -278,10 +261,8 @@ type UserPrincipalBackend interface {
CurrentUserPrincipal(ctx context.Context) (string, error)
}
// Capability indicates the features that a server supports.
type Capability string
// ServePrincipalOptions holds options for ServePrincipal.
type ServePrincipalOptions struct {
CurrentUserPrincipalPath string
HomeSets []BackendSuppliedHomeSet
@ -299,7 +280,7 @@ func ServePrincipal(w http.ResponseWriter, r *http.Request, options *ServePrinci
allow := []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}
w.Header().Add("DAV", strings.Join(caps, ", "))
w.Header().Add("Allow", strings.Join(allow, ", "))
w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusNoContent)
case "PROPFIND":
if err := servePrincipalPropfind(w, r, options); err != nil {
internal.ServeError(w, err)

View File

@ -5,11 +5,8 @@ package webdav
import (
"time"
"github.com/emersion/go-webdav/internal"
)
// FileInfo holds information about a WebDAV file.
type FileInfo struct {
Path string
Size int64
@ -18,46 +15,3 @@ type FileInfo struct {
MIMEType string
ETag string
}
type CreateOptions struct {
IfMatch ConditionalMatch
IfNoneMatch ConditionalMatch
}
type CopyOptions struct {
NoRecursive bool
NoOverwrite bool
}
type MoveOptions struct {
NoOverwrite bool
}
// ConditionalMatch represents the value of a conditional header
// according to RFC 2068 section 14.25 and RFC 2068 section 14.26
// The (optional) value can either be a wildcard or an ETag.
type ConditionalMatch string
func (val ConditionalMatch) IsSet() bool {
return val != ""
}
func (val ConditionalMatch) IsWildcard() bool {
return val == "*"
}
func (val ConditionalMatch) ETag() (string, error) {
var e internal.ETag
if err := e.UnmarshalText([]byte(val)); err != nil {
return "", err
}
return string(e), nil
}
func (val ConditionalMatch) MatchETag(etag string) (bool, error) {
if val.IsWildcard() {
return true, nil
}
t, err := val.ETag()
return t == etag, err
}