Compare commits

...

50 Commits

Author SHA1 Message Date
6f60a899bf
Add error returning for missing error output paths
Co-Authored-By: Captain ALM <captainalm@captainalm.com>
2025-01-26 18:49:07 +00:00
d28f08a32d
Change this too 2025-01-26 18:49:07 +00:00
f5b508b766
Respond with 200 OK instead of 204 No Content 2025-01-26 18:49:06 +00:00
63f15c0ec6
AddCurrentUserPrivilegeSet to find caldav 2025-01-26 18:49:06 +00:00
906087cd59
Add CurrentUserPrivilegeSet to find carddav 2025-01-26 18:49:00 +00:00
Simon Ser
3cc7466ac9 internal: add PropFindValue
NewPropFindResponse uses callbacks to lazily build the response.
However, some props are static: they don't require any processing
to generate. Add a small helper to reduce boilerplate a bit.
2025-01-13 23:00:32 +01:00
Simon Ser
9d778f4072 webdav: add support for If-Match/If-None-Match in FileSystem.Create 2024-12-09 22:31:59 +01:00
Simon Ser
93fee5bcf0 webdav: don't leave a partially uploaded file behind on error 2024-12-09 09:19:16 +01:00
Simon Ser
7f8c17ad71 readme: drop CI badge
The GitHub UI already displays that information.
2024-07-13 15:55:26 +02:00
Thomas Müller
810c51fa2d webdav: PUT response has no body and therefore should not have a content length header 2024-06-06 16:53:57 +02:00
Conrad Hoffmann
21f251fa1d Update go-ical
It's only a dependency update in go-ical, but it allows gettin rid of
rrule-go v.1.7, which is nice.
2024-04-19 16:39:09 +02:00
Thomas Müller
ff8598015d webdav: respond PUT request with 204/No Content in case the file already existed before putting 2024-04-17 15:51:26 +02:00
Thomas Müller
ffd81465fd webdav: FileSystem.Create() returns FileInfo and is used to set PUT response headers 2024-04-17 15:16:35 +02:00
Thomas Müller
948f33c2fc internal: use application/xml instead of text/xml which is deprecated 2024-04-11 17:16:25 +02:00
Thomas Müller
381b8a3cee carddav: add unit test for CardDAV mkcol 2024-04-09 12:55:19 +02:00
Thomas Müller
df447dc627 webdav: change FileSystem.Create to give implementations more control 2024-04-09 12:46:16 +02:00
Thomas Müller
3ed9a4f052 carddav, caldav: add missing headers on PUT
ETag and Last-Modified should be set to the new calendar object or
address object properties.
2024-03-28 11:22:46 +01:00
Conrad Hoffmann
25f1014ef2 internal: no status element in propstat responses
Responses that contain propstat elements do not contain their own
top-level status element, only the status elements inside the propstat
element.

See https://datatracker.ietf.org/doc/html/rfc4918#section-14.24 or any
of the examples for PROPFIND/PROPPATCH, starting e.g. here:
https://datatracker.ietf.org/doc/html/rfc4918#section-9.1.3
2024-02-08 23:12:59 +01:00
Conrad Hoffmann
ad1fe1c5a8
caldav, carddav: displayname and desription are optional
Both the displayname and the description can be absent for both
calendars and address books. If this is the case they should not show up
in PROPFIND responses as empty string.
2024-02-08 17:15:04 +01:00
Thomas Müller
0ea114ec79
caldav: add MKCOL support 2024-02-08 17:08:41 +01:00
Simon Ser
20fad80dff carddav: return HTTP 501 error instead of panicing 2024-02-07 17:26:50 +01:00
Dan Berglund
12d8b4bf62
caldav: return proper HTTP 501 instead of panicing
It seems like e.g. Apples reminders likes to send `PropPatch`, and
currently this just fills up my logs because of the panic. I thought it
would be better to signal that this isn't supported yet, which should
hopefully make it easier to dig through the logs.
2024-02-07 17:25:57 +01:00
Simon Ser
fbcd08d64a carddav: pass pointer in CreateAddressBook
The struct is a bit too large to pass by value.
2024-02-07 17:24:04 +01:00
Simon Ser
f1d56f2437 internal: add IsRequestEmpty 2024-02-07 17:23:17 +01:00
Conrad Hoffmann
71bd967b43 carddav: support address book creation/deletion
Now that the handling for multiple address books is in place, this
commit adds initial support for creation and deletion of address books.

These operations obviously require support from the backend, so the
interface gains two new methods. All properties of the address book
passed to `CreateAddressBook()` may be unset (e.g. when a client sends a
MKCOL request without a body), except for the path, which is always set.
It is up to the backend to put any desired default values in place.
2024-02-07 17:20:48 +01:00
Simon Ser
80d77a977a webdav: stop using os errors in FileSystem interface
Use NewHTTPError instead.

Closes: https://github.com/emersion/go-webdav/issues/20
2024-02-06 15:23:30 +01:00
Conrad Hoffmann
eaac65215b carddav: support multiple address books
This is the equivalent of #127 (and #140) for CardDAV and finally allows
backends to serve different address books to different users.

While I'm breaking the interface, correct one last instance of
"Addressbook" to "AddressBook" (in `AddressBookHomeSetPath`).
2024-02-02 17:48:22 +01:00
Conrad Hoffmann
e3ba95cd77 caldav: add path to interface QueryCalendarObjects
This was missing for proper multi-calendar support.
2024-02-02 14:28:22 +01:00
Conrad Hoffmann
5b5b542f2f caldav: fix match on open time ranges
Matches on open time ranges (i.e. no end date) were not properly
handled, as `end` is simply the zero time, which confuses the
`.Before()` and `.After()` logic employed here.

This commit fixes that by adding the appropriate `.IsZero()` checks and
also adds a test case.

The current behavior unfortunately broke compatibility with DAVx5, which
by default queries only events less than 90 days ago (by using an open
time range).
2024-02-01 14:36:51 +01:00
Simon Ser
ced348a58f webdav: move ConditionalMatch to webdav.go
It's not an XML element.
2024-01-18 13:37:21 +01:00
Simon Ser
b821d8c1ea webdav: introduce MoveOptions 2024-01-18 13:28:50 +01:00
Simon Ser
790ebfc5f8 webdav: rename MoveAll to Move 2024-01-18 13:28:50 +01:00
Simon Ser
4493704689 webdav: introduce CopyOptions 2024-01-18 13:28:50 +01:00
Simon Ser
b043bbd965 internal/server: handle PROPFIND without body
See RFC 4918 section 9.1.
2024-01-08 14:58:24 +01:00
Simon Ser
75d3041b41 webdav: rename Client.Readdir to ReadDir
This is a more idiomatic name, and we've broken the API already
to add the ctx argument.
2024-01-08 14:35:56 +01:00
Simon Ser
751741d87e webdav: add/improve doc comments 2024-01-08 14:35:19 +01:00
Simon Ser
7e076258d6 caldav: add DiscoverContextURL 2023-12-27 23:16:49 +01:00
Simon Ser
174622c1eb carddav: rename Discover to DiscoverContextURL
This only performs part of the discovery process.
2023-12-27 23:11:51 +01:00
Simon Ser
d033e09835 webdav: add context to FileSystem 2023-12-19 21:29:54 +01:00
Simon Ser
379a418130 Add context for clients 2023-12-19 21:29:37 +01:00
Simon Ser
0e58dbb003 caldav, carddav: take header when populating object
References: https://github.com/emersion/go-webdav/pull/134
2023-12-18 18:18:56 +01:00
Sebastien Binet
7d337ac048 internal: fix always-true interface comparison
This CL corrects the following bug uncovered by staticcheck:

```
  internal/elements.go:148:6: this comparison is always true (SA4023)
    internal/elements.go:146:18: the lhs of the comparison gets its value from here and has a concrete type
```

Signed-off-by: Sebastien Binet <binet@cern.ch>
2023-12-15 15:07:59 +01:00
Simon Ser
dddaf279ed Upgrade dependencies 2023-09-10 14:52:02 +09:00
Simon Ser
fc4ea1aae2 caldav, carddav: drop unnecessary nil check
Closes: https://github.com/emersion/go-webdav/issues/92
2023-08-25 13:35:33 +02:00
Dan Berglund
571eba7c02
caldav: add multi-calendar support 2023-08-21 13:06:59 +02:00
Simon Ser
b46cbafa6f
readme: switch back to pkg.go.dev for docs 2023-08-15 08:29:01 +02:00
Simon Ser
0fb0a675ab carddav: handle PROPFIND on root
Same as 7dd64908d287 ("caldav: handle PROPFIND on root") but for
CardDAV.
2023-07-06 12:14:17 +02:00
Dan Berglund
7dd64908d2
caldav: handle PROPFIND on root
It seems like the Reminders app in iOS/macOS does this request as
the first thing when setting up an account, so it seems reasonable to
handle it for us.

This just returns the most basic current-user-principal now, but that
should hopefully be enough to continue the process.
2023-07-06 12:12:07 +02:00
Dan Berglund
46dbba12fe
caldav: return SupportedComponentSet in PROPFIND
I started using this project to export tasks over CalDav, more
specifically to Reminders on iOS/macOS. I quickly realized that
even if you specify that `SupportedComponentSet` contains `VTODO`, that
isn't reflected properly when doing the `PROPFIND`.

This patch should fix that, while keeping the behaviour of defaulting to
`VEVENT` for propfind. Also added some tests to make sure that I didn't
break anything (Which I hope I didn't 😅).
2023-07-03 10:47:34 +02:00
Simon Ser
150f74a6f0 Add GitHub issue template 2023-01-04 11:00:51 +01:00
24 changed files with 1124 additions and 463 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,7 +1,6 @@
# 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?)
[![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-webdav.svg)](https://pkg.go.dev/github.com/emersion/go-webdav)
A Go library for [WebDAV], [CalDAV] and [CardDAV].

View File

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

View File

@ -228,3 +228,10 @@ 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,7 +138,6 @@ 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
@ -155,15 +154,15 @@ func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error
}
// Event starts in time range
if eventStart.After(start) && eventStart.Before(end) {
if eventStart.After(start) && (end.IsZero() || eventStart.Before(end)) {
return true, nil
}
// Event ends in time range
if eventEnd.After(start) && eventEnd.Before(end) {
if eventEnd.After(start) && (end.IsZero() || eventEnd.Before(end)) {
return true, nil
}
// Event covers entire time range plus some
if eventStart.Before(start) && eventEnd.After(end) {
if eventStart.Before(start) && (!end.IsZero() && eventEnd.After(end)) {
return true, nil
}
return false, nil
@ -172,13 +171,11 @@ 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) && ptime.Before(end) {
if ptime.After(start) && (end.IsZero() || ptime.Before(end)) {
return true, nil
}
return false, nil

View File

@ -209,6 +209,23 @@ 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,8 +17,6 @@ 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
@ -32,11 +30,15 @@ type PutCalendarObjectOptions struct {
// Backend is a CalDAV server backend.
type Backend interface {
CalendarHomeSetPath(ctx context.Context) (string, error)
Calendar(ctx context.Context) (*Calendar, error)
CreateCalendar(ctx context.Context, calendar *Calendar) error
ListCalendars(ctx context.Context) ([]Calendar, error)
GetCalendar(ctx context.Context, path string) (*Calendar, error)
GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
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)
ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error)
QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error)
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error)
DeleteCalendarObject(ctx context.Context, path string) error
webdav.UserPrincipalBackend
@ -76,7 +78,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Backend: h.Backend,
Prefix: strings.TrimSuffix(h.Prefix, "/"),
}
hh := internal.Handler{&b}
hh := internal.Handler{Backend: &b}
hh.ServeHTTP(w, r)
}
@ -214,7 +216,7 @@ func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *cal
}
q.CompFilter = *cf
cos, err := h.Backend.QueryCalendarObjects(r.Context(), &q)
cos, err := h.Backend.QueryCalendarObjects(r.Context(), r.URL.Path, &q)
if err != nil {
return err
}
@ -371,6 +373,12 @@ 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 {
@ -418,24 +426,21 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
}
}
case resourceTypeCalendar:
// TODO for multiple calendars, look through all of them
ab, err := b.Backend.Calendar(r.Context())
ab, err := b.Backend.GetCalendar(r.Context(), r.URL.Path)
if err != nil {
return nil, err
}
if r.URL.Path == ab.Path {
resp, err := b.propFindCalendar(r.Context(), propfind, ab)
resp, err := b.propFindCalendar(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
resps = append(resps, resps_...)
}
case resourceTypeCalendarObject:
ao, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
@ -453,6 +458,21 @@ 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 {
@ -464,15 +484,13 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.
}
props := map[xml.Name]internal.PropFindFunc{
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
},
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
calendarHomeSetName: internal.PropFindValue(&calendarHomeSet{
Href: internal.Href{Path: homeSetPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(principalPath, propfind, props)
}
@ -489,12 +507,10 @@ 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) {
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
},
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return internal.NewResourceType(internal.CollectionName), nil
},
internal.CurrentUserPrincipalName: internal.PropFindValue(&internal.CurrentUserPrincipal{
Href: internal.Href{Path: principalPath},
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName)),
}
return internal.NewPropFindResponse(homeSetPath, propfind, props)
}
@ -508,41 +524,47 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
},
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
},
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, calendarName)),
calendarDescriptionName: internal.PropFindValue(&calendarDescription{
Description: cal.Description,
}),
supportedCalendarDataName: internal.PropFindValue(&supportedCalendarData{
Types: []calendarDataType{
{ContentType: ical.MIMEType, Version: "2.0"},
},
}),
supportedCalendarComponentSetName: func(*internal.RawXMLValue) (interface{}, error) {
components := []comp{}
if cal.SupportedComponentSet != nil {
for _, name := range cal.SupportedComponentSet {
components = append(components, comp{Name: name})
}
} else {
components = append(components, comp{Name: ical.CompEvent})
}
return &supportedCalendarComponentSet{
Comp: []comp{
{Name: ical.CompEvent},
},
Comp: components,
}, nil
},
}
if cal.Description != "" {
props[calendarDescriptionName] = func(*internal.RawXMLValue) (interface{}, error) {
return &calendarDescription{Description: cal.Description}, nil
}
if cal.Name != "" {
props[internal.DisplayNameName] = internal.PropFindValue(&internal.DisplayName{
Name: cal.Name,
})
}
if cal.Description != "" {
props[calendarDescriptionName] = internal.PropFindValue(&calendarDescription{
Description: cal.Description,
})
}
if cal.MaxResourceSize > 0 {
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &maxResourceSize{Size: cal.MaxResourceSize}, nil
}
props[maxResourceSizeName] = internal.PropFindValue(&maxResourceSize{
Size: cal.MaxResourceSize,
})
}
props[internal.CurrentUserPrivilegeSetName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.CurrentUserPrivilegeSet{Privilege: internal.NewAllPrivileges()}, nil
}
// TODO: CALDAV:calendar-timezone, CALDAV:supported-calendar-component-set, CALDAV:min-date-time, CALDAV:max-date-time, CALDAV:max-instances, CALDAV:max-attendees-per-instance
@ -551,22 +573,20 @@ 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) {
// TODO iterate over all calendars once having multiple is supported
ab, err := b.Backend.Calendar(ctx)
abs, err := b.Backend.ListCalendars(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
}
@ -585,9 +605,9 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
}
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
},
internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentType{Type: ical.MIMEType}, nil
},
internal.GetContentTypeName: internal.PropFindValue(&internal.GetContentType{
Type: ical.MIMEType,
}),
// TODO: calendar-data can only be used in REPORT requests
calendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
var buf bytes.Buffer
@ -600,20 +620,20 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
}
if co.ContentLength > 0 {
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentLength{Length: co.ContentLength}, nil
}
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: co.ContentLength,
})
}
if !co.ModTime.IsZero() {
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetLastModified{LastModified: internal.Time(co.ModTime)}, nil
}
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(co.ModTime),
})
}
if co.ETag != "" {
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetETag{ETag: internal.ETag(co.ETag)}, nil
}
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(co.ETag),
})
}
return internal.NewPropFindResponse(co.Path, propfind, props)
@ -621,7 +641,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, &dataReq)
aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq)
if err != nil {
return nil, err
}
@ -638,10 +658,10 @@ func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *inte
}
func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*internal.Response, error) {
panic("TODO")
return nil, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: PropPatch not implemented")
}
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
ifNoneMatch := webdav.ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := webdav.ConditionalMatch(r.Header.Get("If-Match"))
@ -652,26 +672,39 @@ func (b *backend) Put(r *http.Request) (*internal.Href, error) {
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err)
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err)
}
if t != ical.MIMEType {
// TODO: send CALDAV:supported-calendar-data error
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t)
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t)
}
// TODO: check CALDAV:max-resource-size precondition
cal, err := ical.NewDecoder(r.Body).Decode()
if err != nil {
// TODO: send CALDAV:valid-calendar-data error
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
}
loc, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts)
co, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts)
if err != nil {
return nil, err
return err
}
return &internal.Href{Path: loc}, nil
if co.ETag != "" {
w.Header().Set("ETag", internal.ETag(co.ETag).String())
}
if !co.ModTime.IsZero() {
w.Header().Set("Last-Modified", co.ModTime.UTC().Format(http.TimeFormat))
}
if co.Path != "" {
w.Header().Set("Location", co.Path)
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
return nil
}
func (b *backend) Delete(r *http.Request) error {
@ -679,15 +712,36 @@ func (b *backend) Delete(r *http.Request) error {
}
func (b *backend) Mkcol(r *http.Request) error {
panic("TODO")
if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar {
return internal.HTTPErrorf(http.StatusForbidden, "caldav: calendar creation not allowed at given location")
}
cal := Calendar{
Path: r.URL.Path,
}
if !internal.IsRequestBodyEmpty(r) {
var m mkcolReq
if err := internal.DecodeXMLRequest(r, &m); err != nil {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: error parsing mkcol request: %s", err.Error())
}
if !m.ResourceType.Is(internal.CollectionName) || !m.ResourceType.Is(calendarName) {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
cal.Name = m.DisplayName
// TODO ...
}
return b.Backend.CreateCalendar(r.Context(), &cal)
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
panic("TODO")
return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Copy not implemented")
}
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
panic("TODO")
return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Move not implemented")
}
// https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
@ -708,7 +762,7 @@ const (
)
func NewPreconditionError(err PreconditionType) error {
name := xml.Name{"urn:ietf:params:xml:ns:caldav", string(err)}
name := xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: string(err)}
elem := internal.NewRawXMLElement(name, nil, nil)
return &internal.HTTPError{
Code: 409,

235
caldav/server_test.go Normal file
View File

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

View File

@ -12,7 +12,9 @@ import (
"github.com/emersion/go-webdav"
)
type testBackend struct{}
type testBackend struct {
addressBooks []AddressBook
}
type contextKey string
@ -37,22 +39,46 @@ 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) AddressBook(ctx context.Context) (*AddressBook, error) {
func (*testBackend) ListAddressBooks(ctx context.Context) ([]AddressBook, error) {
p := ctx.Value(addressBookPathKey).(string)
return &AddressBook{
Path: p,
Name: "My contacts",
Description: "Default address book",
MaxResourceSize: 1024,
SupportedAddressData: nil,
return []AddressBook{
AddressBook{
Path: p,
Name: "My contacts",
Description: "Default address book",
MaxResourceSize: 1024,
SupportedAddressData: nil,
},
}, nil
}
func (b *testBackend) GetAddressBook(ctx context.Context, path string) (*AddressBook, error) {
abs, err := b.ListAddressBooks(ctx)
if err != nil {
panic(err)
}
for _, ab := range abs {
if ab.Path == path {
return &ab, nil
}
}
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
func (b *testBackend) CreateAddressBook(ctx context.Context, ab *AddressBook) error {
b.addressBooks = append(b.addressBooks, *ab)
return nil
}
func (*testBackend) DeleteAddressBook(ctx context.Context, path string) error {
panic("TODO: implement")
}
func (*testBackend) GetAddressObject(ctx context.Context, path string, req *AddressDataRequest) (*AddressObject, error) {
if path == alicePath {
card, err := vcard.NewDecoder(strings.NewReader(aliceData)).Decode()
@ -68,7 +94,11 @@ func (*testBackend) GetAddressObject(ctx context.Context, path string, req *Addr
}
}
func (b *testBackend) ListAddressObjects(ctx context.Context, req *AddressDataRequest) ([]AddressObject, error) {
func (b *testBackend) ListAddressObjects(ctx context.Context, path string, req *AddressDataRequest) ([]AddressObject, error) {
p := ctx.Value(addressBookPathKey).(string)
if !strings.HasPrefix(path, p) {
return nil, webdav.NewHTTPError(404, fmt.Errorf("Not found"))
}
alice, err := b.GetAddressObject(ctx, alicePath, req)
if err != nil {
return nil, err
@ -77,11 +107,11 @@ func (b *testBackend) ListAddressObjects(ctx context.Context, req *AddressDataRe
return []AddressObject{*alice}, nil
}
func (*testBackend) QueryAddressObjects(ctx context.Context, query *AddressBookQuery) ([]AddressObject, error) {
func (*testBackend) QueryAddressObjects(ctx context.Context, path string, query *AddressBookQuery) ([]AddressObject, error) {
panic("TODO: implement")
}
func (*testBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (loc string, err error) {
func (*testBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *PutAddressObjectOptions) (*AddressObject, error) {
panic("TODO: implement")
}
@ -113,6 +143,7 @@ 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) {
@ -135,21 +166,21 @@ func TestAddressBookDiscovery(t *testing.T) {
if err != nil {
t.Fatalf("error creating client: %s", err)
}
cup, err := client.FindCurrentUserPrincipal()
cup, err := client.FindCurrentUserPrincipal(ctx)
if err != nil {
t.Fatalf("error finding user principal url: %s", err)
}
if cup != tc.currentUserPrincipal {
t.Fatalf("Found current user principal URL '%s', expected '%s'", cup, tc.currentUserPrincipal)
}
hsp, err := client.FindAddressBookHomeSet(cup)
hsp, err := client.FindAddressBookHomeSet(ctx, cup)
if err != nil {
t.Fatalf("error finding home set path: %s", err)
}
if hsp != tc.homeSetPath {
t.Fatalf("Found home set path '%s', expected '%s'", hsp, tc.homeSetPath)
}
abs, err := client.FindAddressBooks(hsp)
abs, err := client.FindAddressBooks(ctx, hsp)
if err != nil {
t.Fatalf("error finding address books: %s", err)
}
@ -162,3 +193,50 @@ 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,38 +16,10 @@ import (
"github.com/emersion/go-webdav/internal"
)
// Discover performs a DNS-based CardDAV service discovery as described in
// RFC 6352 section 11. It returns the URL to the CardDAV server.
func Discover(domain string) (string, error) {
// Only lookup carddavs (not carddav), plaintext connections are insecure
_, addrs, err := net.LookupSRV("carddavs", "tcp", domain)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsTemporary {
return "", err
}
} else if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("carddav: domain doesn't have an SRV record")
}
addr := addrs[0]
target := strings.TrimSuffix(addr.Target, ".")
if target == "" {
return "", fmt.Errorf("carddav: empty target in SRV record")
}
// 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
// DiscoverContextURL performs a DNS-based CardDAV service discovery as
// described in RFC 6352 section 11. It returns the URL to the CardDAV server.
func DiscoverContextURL(ctx context.Context, domain string) (string, error) {
return internal.DiscoverContextURL(ctx, "carddavs", domain)
}
// Client provides access to a remote CardDAV server.
@ -69,8 +41,8 @@ func NewClient(c webdav.HTTPClient, endpoint string) (*Client, error) {
return &Client{wc, ic}, nil
}
func (c *Client) HasSupport() error {
classes, _, err := c.ic.Options("")
func (c *Client) HasSupport(ctx context.Context) error {
classes, _, err := c.ic.Options(ctx, "")
if err != nil {
return err
}
@ -81,9 +53,9 @@ func (c *Client) HasSupport() error {
return nil
}
func (c *Client) FindAddressBookHomeSet(principal string) (string, error) {
func (c *Client) FindAddressBookHomeSet(ctx context.Context, principal string) (string, error) {
propfind := internal.NewPropNamePropFind(addressBookHomeSetName)
resp, err := c.ic.PropFindFlat(principal, propfind)
resp, err := c.ic.PropFindFlat(ctx, principal, propfind)
if err != nil {
return "", err
}
@ -104,7 +76,7 @@ func decodeSupportedAddressData(supported *supportedAddressData) []AddressDataTy
return l
}
func (c *Client) FindAddressBooks(addressBookHomeSet string) ([]AddressBook, error) {
func (c *Client) FindAddressBooks(ctx context.Context, addressBookHomeSet string) ([]AddressBook, error) {
propfind := internal.NewPropNamePropFind(
internal.ResourceTypeName,
internal.DisplayNameName,
@ -112,7 +84,7 @@ func (c *Client) FindAddressBooks(addressBookHomeSet string) ([]AddressBook, err
maxResourceSizeName,
supportedAddressDataName,
)
ms, err := c.ic.PropFind(addressBookHomeSet, internal.DepthOne, propfind)
ms, err := c.ic.PropFind(ctx, addressBookHomeSet, internal.DepthOne, propfind)
if err != nil {
return nil, err
}
@ -271,7 +243,7 @@ func decodeAddressList(ms *internal.MultiStatus) ([]AddressObject, error) {
return addrs, nil
}
func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
func (c *Client) QueryAddressBook(ctx context.Context, addressBook string, query *AddressBookQuery) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&query.DataRequest)
if err != nil {
return nil, err
@ -297,7 +269,7 @@ func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) (
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req)
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
@ -305,7 +277,7 @@ func (c *Client) QueryAddressBook(addressBook string, query *AddressBookQuery) (
return decodeAddressList(ms)
}
func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
func (c *Client) MultiGetAddressBook(ctx context.Context, path string, multiGet *AddressBookMultiGet) ([]AddressObject, error) {
propReq, err := encodeAddressPropReq(&multiGet.DataRequest)
if err != nil {
return nil, err
@ -313,7 +285,7 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
addressbookMultiget := addressbookMultiget{Prop: propReq}
if multiGet == nil || len(multiGet.Paths) == 0 {
if len(multiGet.Paths) == 0 {
href := internal.Href{Path: path}
addressbookMultiget.Hrefs = []internal.Href{href}
} else {
@ -330,7 +302,7 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
req.Header.Add("Depth", "1")
ms, err := c.ic.DoMultiStatus(req)
ms, err := c.ic.DoMultiStatus(req.WithContext(ctx))
if err != nil {
return nil, err
}
@ -338,29 +310,29 @@ func (c *Client) MultiGetAddressBook(path string, multiGet *AddressBookMultiGet)
return decodeAddressList(ms)
}
func populateAddressObject(ao *AddressObject, resp *http.Response) error {
if loc := resp.Header.Get("Location"); loc != "" {
func populateAddressObject(ao *AddressObject, h http.Header) error {
if loc := h.Get("Location"); loc != "" {
u, err := url.Parse(loc)
if err != nil {
return err
}
ao.Path = u.Path
}
if etag := resp.Header.Get("ETag"); etag != "" {
if etag := h.Get("ETag"); etag != "" {
etag, err := strconv.Unquote(etag)
if err != nil {
return err
}
ao.ETag = etag
}
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
if contentLength := h.Get("Content-Length"); contentLength != "" {
n, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return err
}
ao.ContentLength = n
}
if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" {
if lastModified := h.Get("Last-Modified"); lastModified != "" {
t, err := http.ParseTime(lastModified)
if err != nil {
return err
@ -371,14 +343,14 @@ func populateAddressObject(ao *AddressObject, resp *http.Response) error {
return nil
}
func (c *Client) GetAddressObject(path string) (*AddressObject, error) {
func (c *Client) GetAddressObject(ctx context.Context, path string) (*AddressObject, error) {
req, err := c.ic.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", vcard.MIMEType)
resp, err := c.ic.Do(req)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
@ -401,13 +373,13 @@ func (c *Client) GetAddressObject(path string) (*AddressObject, error) {
Path: resp.Request.URL.Path,
Card: card,
}
if err := populateAddressObject(ao, resp); err != nil {
if err := populateAddressObject(ao, resp.Header); err != nil {
return nil, err
}
return ao, nil
}
func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject, error) {
func (c *Client) PutAddressObject(ctx context.Context, path string, card vcard.Card) (*AddressObject, error) {
// TODO: add support for If-None-Match and If-Match
// TODO: some servers want a Content-Length header, so we can't stream the
@ -432,14 +404,14 @@ func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject,
}
req.Header.Set("Content-Type", vcard.MIMEType)
resp, err := c.ic.Do(req)
resp, err := c.ic.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
resp.Body.Close()
ao := &AddressObject{Path: path}
if err := populateAddressObject(ao, resp); err != nil {
if err := populateAddressObject(ao, resp.Header); err != nil {
return nil, err
}
return ao, nil
@ -447,7 +419,7 @@ func (c *Client) PutAddressObject(path string, card vcard.Card) (*AddressObject,
// SyncCollection performs a collection synchronization operation on the
// specified resource, as defined in RFC 6578.
func (c *Client) SyncCollection(path string, query *SyncQuery) (*SyncResponse, error) {
func (c *Client) SyncCollection(ctx context.Context, path string, query *SyncQuery) (*SyncResponse, error) {
var limit *internal.Limit
if query.Limit > 0 {
limit = &internal.Limit{NResults: uint(query.Limit)}
@ -458,7 +430,7 @@ func (c *Client) SyncCollection(path string, query *SyncQuery) (*SyncResponse, e
return nil, err
}
ms, err := c.ic.SyncCollection(path, query.SyncToken, internal.DepthOne, limit, propReq)
ms, err := c.ic.SyncCollection(ctx, path, query.SyncToken, internal.DepthOne, limit, propReq)
if err != nil {
return nil, err
}

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

12
go.sum
View File

@ -1,6 +1,6 @@
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=
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=

View File

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

View File

@ -1,6 +1,7 @@
package internal
import (
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
@ -22,6 +23,8 @@ var (
GetETagName = xml.Name{Namespace, "getetag"}
CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"}
CurrentUserPrivilegeSetName = xml.Name{Namespace, "current-user-privilege-set"}
)
type Status struct {
@ -143,7 +146,10 @@ func (resp *Response) Err() error {
return nil
}
var err error = resp.Error
var err error
if resp.Error != nil {
err = resp.Error
}
if resp.ResponseDescription != "" {
if err != nil {
err = fmt.Errorf("%v (%w)", resp.ResponseDescription, err)
@ -348,7 +354,7 @@ type Time time.Time
func (t *Time) UnmarshalText(b []byte) error {
tt, err := http.ParseTime(string(b))
if err != nil {
return err
return errors.New(err.Error() + " : time_data : " + base64.StdEncoding.EncodeToString(b))
}
*t = Time(tt)
return nil
@ -414,6 +420,30 @@ 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,6 +4,7 @@ import (
"encoding/xml"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
@ -27,9 +28,13 @@ 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 {
t, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if t != "application/xml" && t != "text/xml" {
if !isContentXML(r.Header) {
return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request")
}
@ -39,8 +44,13 @@ func DecodeXMLRequest(r *http.Request, v interface{}) error {
return nil
}
func IsRequestBodyEmpty(r *http.Request) bool {
_, err := r.Body.Read(nil)
return err == io.EOF
}
func ServeXML(w http.ResponseWriter) *xml.Encoder {
w.Header().Add("Content-Type", "text/xml; charset=\"utf-8\"")
w.Header().Add("Content-Type", "application/xml; charset=\"utf-8\"")
w.Write([]byte(xml.Header))
return xml.NewEncoder(w)
}
@ -56,7 +66,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(r *http.Request) (*Href, error)
Put(w http.ResponseWriter, r *http.Request) error
Delete(r *http.Request) error
Mkcol(r *http.Request) error
Copy(r *http.Request, dest *Href, recursive, overwrite bool) (created bool, err error)
@ -78,17 +88,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case http.MethodGet, http.MethodHead:
err = h.Backend.HeadGet(w, r)
case http.MethodPut:
var href *Href
href, err = h.Backend.Put(r)
if err == nil {
// TODO: Last-Modified, ETag, Content-Type if the request has
// been copied verbatim
if href != nil {
w.Header().Set("Location", (*url.URL)(href).String())
}
// TODO: http.StatusNoContent if the resource already existed
w.WriteHeader(http.StatusCreated)
}
err = h.Backend.Put(w, r)
case http.MethodDelete:
// TODO: send a multistatus in case of partial failure
err = h.Backend.Delete(r)
@ -125,14 +125,22 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) error {
w.Header().Add("DAV", strings.Join(caps, ", "))
w.Header().Add("Allow", strings.Join(allow, ", "))
w.WriteHeader(http.StatusNoContent)
w.WriteHeader(http.StatusOK)
return nil
}
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error {
var propfind PropFind
if err := DecodeXMLRequest(r, &propfind); err != nil {
return err
if isContentXML(r.Header) {
if err := DecodeXMLRequest(r, &propfind); err != nil {
return err
}
} else {
var b [1]byte
if _, err := r.Body.Read(b[:]); err != io.EOF {
return HTTPErrorf(http.StatusBadRequest, "webdav: unsupported request body")
}
propfind.AllProp = &struct{}{}
}
depth := DepthInfinity
@ -154,13 +162,17 @@ 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 := NewOKResponse(path)
resp := &Response{Hrefs: []Href{Href{Path: path}}}
if _, ok := props[ResourceTypeName]; !ok {
props[ResourceTypeName] = func(*RawXMLValue) (interface{}, error) {
return NewResourceType(), nil
}
props[ResourceTypeName] = PropFindValue(NewResourceType())
}
if propfind.PropName != nil {
@ -179,9 +191,8 @@ func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]Pro
code := http.StatusOK
if err != nil {
// TODO: don't throw away error message here
code = HTTPErrorFromError(err).Code
val = emptyVal
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
}
if err := resp.EncodeProp(code, val); err != nil {
@ -202,8 +213,8 @@ func NewPropFindResponse(path string, propfind *PropFind, props map[xml.Name]Pro
f, ok := props[xmlName]
if ok {
if v, err := f(&raw); err != nil {
// TODO: don't throw away error message here
code = HTTPErrorFromError(err).Code
val = NewRawXMLElement(xmlName, []xml.Attr{{Name: xml.Name{Space: "ERR", Local: "Error"}, Value: err.Error()}}, nil)
} else {
code = http.StatusOK
val = v

127
server.go
View File

@ -14,14 +14,14 @@ import (
// FileSystem is a WebDAV server backend.
type FileSystem interface {
Open(name string) (io.ReadCloser, error)
Stat(name string) (*FileInfo, error)
Readdir(name string, recursive bool) ([]FileInfo, error)
Create(name string) (io.WriteCloser, error)
RemoveAll(name string) error
Mkdir(name string) error
Copy(name, dest string, recursive, overwrite bool) (created bool, err error)
MoveAll(name, dest string, overwrite bool) (created bool, err error)
Open(ctx context.Context, name string) (io.ReadCloser, error)
Stat(ctx context.Context, name string) (*FileInfo, error)
ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error)
Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fileInfo *FileInfo, created bool, err error)
RemoveAll(ctx context.Context, name string) error
Mkdir(ctx context.Context, name string) error
Copy(ctx context.Context, name, dest string, options *CopyOptions) (created bool, err error)
Move(ctx context.Context, name, dest string, options *MoveOptions) (created bool, err error)
}
// Handler handles WebDAV HTTP requests. It can be used to create a WebDAV
@ -38,14 +38,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
b := backend{h.FileSystem}
hh := internal.Handler{&b}
hh := internal.Handler{Backend: &b}
hh.ServeHTTP(w, r)
}
// NewHTTPError creates a new error that is associated with an HTTP status code
// and optionally an error that lead to it. Backends can use this functions to
// return errors that convey some semantics (e.g. 404 not found, 403 access
// denied, etc) while also providing an (optional) arbitrary error context
// 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.URL.Path)
if os.IsNotExist(err) {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if internal.IsNotFound(err) {
return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
} else if err != nil {
return nil, nil, err
@ -79,17 +79,15 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e
}
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
fi, err := b.FileSystem.Stat(r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if err != nil {
return err
}
if fi.IsDir {
return &internal.HTTPError{Code: http.StatusMethodNotAllowed}
}
f, err := b.FileSystem.Open(r.URL.Path)
f, err := b.FileSystem.Open(r.Context(), r.URL.Path)
if err != nil {
return err
}
@ -120,16 +118,14 @@ 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.URL.Path)
if os.IsNotExist(err) {
return nil, &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if err != nil {
return nil, err
}
var resps []internal.Response
if depth != internal.DepthZero && fi.IsDir {
children, err := b.FileSystem.Readdir(r.URL.Path, depth == internal.DepthInfinity)
children, err := b.FileSystem.ReadDir(r.Context(), r.URL.Path, depth == internal.DepthInfinity)
if err != nil {
return nil, err
}
@ -166,26 +162,26 @@ func (b *backend) propFindFile(propfind *internal.PropFind, fi *FileInfo) (*inte
}
if !fi.IsDir {
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentLength{Length: fi.Size}, nil
}
props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
Length: fi.Size,
})
if !fi.ModTime.IsZero() {
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetLastModified{LastModified: internal.Time(fi.ModTime)}, nil
}
props[internal.GetLastModifiedName] = internal.PropFindValue(&internal.GetLastModified{
LastModified: internal.Time(fi.ModTime),
})
}
if fi.MIMEType != "" {
props[internal.GetContentTypeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetContentType{Type: fi.MIMEType}, nil
}
props[internal.GetContentTypeName] = internal.PropFindValue(&internal.GetContentType{
Type: fi.MIMEType,
})
}
if fi.ETag != "" {
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
return &internal.GetETag{ETag: internal.ETag(fi.ETag)}, nil
}
props[internal.GetETagName] = internal.PropFindValue(&internal.GetETag{
ETag: internal.ETag(fi.ETag),
})
}
}
@ -197,41 +193,59 @@ func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*
return nil, internal.HTTPErrorf(http.StatusForbidden, "webdav: PROPPATCH is unsupported")
}
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
wc, err := b.FileSystem.Create(r.URL.Path)
func (b *backend) Put(w http.ResponseWriter, r *http.Request) error {
ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match"))
ifMatch := ConditionalMatch(r.Header.Get("If-Match"))
opts := CreateOptions{
IfNoneMatch: ifNoneMatch,
IfMatch: ifMatch,
}
fi, created, err := b.FileSystem.Create(r.Context(), r.URL.Path, r.Body, &opts)
if err != nil {
return nil, err
}
defer wc.Close()
if _, err := io.Copy(wc, r.Body); err != nil {
return nil, err
return err
}
return nil, wc.Close()
if fi.MIMEType != "" {
w.Header().Set("Content-Type", fi.MIMEType)
}
if !fi.ModTime.IsZero() {
w.Header().Set("Last-Modified", fi.ModTime.UTC().Format(http.TimeFormat))
}
if fi.ETag != "" {
w.Header().Set("ETag", internal.ETag(fi.ETag).String())
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
return nil
}
func (b *backend) Delete(r *http.Request) error {
err := b.FileSystem.RemoveAll(r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
}
return err
return b.FileSystem.RemoveAll(r.Context(), r.URL.Path)
}
func (b *backend) Mkcol(r *http.Request) error {
if r.Header.Get("Content-Type") != "" {
return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request")
}
err := b.FileSystem.Mkdir(r.URL.Path)
if os.IsNotExist(err) {
err := b.FileSystem.Mkdir(r.Context(), r.URL.Path)
if internal.IsNotFound(err) {
return &internal.HTTPError{Code: http.StatusConflict, Err: err}
}
return err
}
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
created, err = b.FileSystem.Copy(r.URL.Path, dest.Path, recursive, overwrite)
options := CopyOptions{
NoRecursive: !recursive,
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Copy(r.Context(), r.URL.Path, dest.Path, &options)
if os.IsExist(err) {
return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
}
@ -239,7 +253,10 @@ func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrit
}
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
created, err = b.FileSystem.MoveAll(r.URL.Path, dest.Path, overwrite)
options := MoveOptions{
NoOverwrite: !overwrite,
}
created, err = b.FileSystem.Move(r.Context(), r.URL.Path, dest.Path, &options)
if os.IsExist(err) {
return false, &internal.HTTPError{http.StatusPreconditionFailed, err}
}
@ -248,7 +265,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 {
@ -261,8 +278,10 @@ 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
@ -280,7 +299,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.StatusNoContent)
w.WriteHeader(http.StatusOK)
case "PROPFIND":
if err := servePrincipalPropfind(w, r, options); err != nil {
internal.ServeError(w, err)

View File

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