Compare commits

...

30 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
18 changed files with 584 additions and 307 deletions

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
[![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-webdav.svg)](https://pkg.go.dev/github.com/emersion/go-webdav)
[![builds.sr.ht status](https://builds.sr.ht/~emersion/go-webdav/commits/master.svg)](https://builds.sr.ht/~emersion/go-webdav/commits/master?)
A Go library for [WebDAV], [CalDAV] and [CardDAV].

View File

@ -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

@ -30,12 +30,15 @@ type PutCalendarObjectOptions struct {
// Backend is a CalDAV server backend.
type Backend interface {
CalendarHomeSetPath(ctx context.Context) (string, error)
CreateCalendar(ctx context.Context, calendar *Calendar) error
ListCalendars(ctx context.Context) ([]Calendar, error)
GetCalendar(ctx context.Context, path string) (*Calendar, error)
GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
ListCalendarObjects(ctx context.Context, path string, 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)
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
@ -75,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)
}
@ -213,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
}
@ -462,12 +465,10 @@ func (b *backend) propFindRoot(ctx context.Context, propfind *internal.PropFind)
}
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(principalPath, propfind, props)
}
@ -483,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)
}
@ -508,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)
}
@ -527,22 +524,15 @@ 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 {
@ -558,16 +548,23 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
},
}
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
@ -608,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
@ -623,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)
@ -661,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"))
@ -675,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 {
@ -702,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
@ -731,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,

View File

@ -182,6 +182,10 @@ type testBackend struct {
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
}
@ -218,14 +222,14 @@ func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *Ca
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) (string, error) {
return "", nil
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, query *CalendarQuery) ([]CalendarObject, error) {
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")
}
@ -163,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

@ -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
}
@ -371,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
}
@ -391,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)
@ -433,12 +431,10 @@ func (b *backend) propFindRoot(ctx context.Context, propfind *internal.PropFind)
}
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(principalPath, propfind, props)
}
@ -448,31 +444,25 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.
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},
}),
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
}
@ -480,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)
}
@ -498,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
}
@ -561,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
@ -576,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)
@ -597,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
}
@ -614,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
}
@ -653,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"))
@ -664,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 (
@ -714,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

@ -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

@ -63,6 +63,18 @@ func fileInfoFromOS(p string, fi os.FileInfo) *FileInfo {
}
}
func errFromOS(err error) error {
if os.IsNotExist(err) {
return NewHTTPError(http.StatusNotFound, err)
} else if os.IsPermission(err) {
return NewHTTPError(http.StatusForbidden, err)
} else if os.IsTimeout(err) {
return NewHTTPError(http.StatusServiceUnavailable, err)
} else {
return err
}
}
func (fs LocalFileSystem) Stat(ctx context.Context, name string) (*FileInfo, error) {
p, err := fs.localPath(name)
if err != nil {
@ -70,7 +82,7 @@ func (fs LocalFileSystem) Stat(ctx context.Context, name string) (*FileInfo, err
}
fi, err := os.Stat(p)
if err != nil {
return nil, err
return nil, errFromOS(err)
}
return fileInfoFromOS(name, fi), nil
}
@ -99,15 +111,57 @@ func (fs LocalFileSystem) ReadDir(ctx context.Context, name string, recursive bo
}
return nil
})
return l, err
return l, errFromOS(err)
}
func (fs LocalFileSystem) Create(ctx context.Context, 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(ctx context.Context, name string) error {
@ -119,10 +173,10 @@ func (fs LocalFileSystem) RemoveAll(ctx context.Context, name string) error {
// WebDAV semantics are that it should return a "404 Not Found" error in
// case the resource doesn't exist. We need to Stat before RemoveAll.
if _, err = os.Stat(p); err != nil {
return err
return errFromOS(err)
}
return os.RemoveAll(p)
return errFromOS(os.RemoveAll(p))
}
func (fs LocalFileSystem) Mkdir(ctx context.Context, name string) error {
@ -130,20 +184,21 @@ func (fs LocalFileSystem) Mkdir(ctx context.Context, name string) error {
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()
@ -169,21 +224,21 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
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 options.NoOverwrite {
return false, os.ErrExist
return false, NewHTTPError(http.StatusPreconditionFailed, os.ErrExist)
}
if err := os.RemoveAll(dstPath); err != nil {
return false, err
return false, errFromOS(err)
}
}
@ -194,7 +249,7 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
if fi.IsDir() {
if err := os.Mkdir(dstPath, srcPerm); err != nil {
return err
return errFromOS(err)
}
} else {
if err := copyRegularFile(srcPath, dstPath, srcPerm); err != nil {
@ -208,7 +263,7 @@ func (fs LocalFileSystem) Copy(ctx context.Context, src, dst string, options *Co
return nil
})
if err != nil {
return false, err
return false, errFromOS(err)
}
return created, nil
@ -226,20 +281,20 @@ func (fs LocalFileSystem) Move(ctx context.Context, src, dst string, options *Mo
if _, err := os.Stat(dstPath); err != nil {
if !os.IsNotExist(err) {
return false, err
return false, errFromOS(err)
}
created = true
} else {
if options.NoOverwrite {
return false, os.ErrExist
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

3
go.mod
View File

@ -3,7 +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-ical v0.0.0-20240127095438-fc1c9d8fb2b6
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
github.com/teambition/rrule-go v1.8.2 // indirect
)

5
go.sum
View File

@ -1,7 +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-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.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
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

@ -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 {
@ -351,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
@ -417,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

@ -44,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)
}
@ -61,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)
@ -83,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)
@ -130,7 +125,7 @@ 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
}
@ -167,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 {
@ -192,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 {
@ -215,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

View File

@ -17,7 +17,7 @@ type FileSystem interface {
Open(ctx context.Context, name string) (io.ReadCloser, error)
Stat(ctx context.Context, name string) (*FileInfo, error)
ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error)
Create(ctx context.Context, name string) (io.WriteCloser, 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)
@ -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}
@ -57,7 +57,7 @@ type backend struct {
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if os.IsNotExist(err) {
if internal.IsNotFound(err) {
return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
} else if err != nil {
return nil, nil, err
@ -80,9 +80,7 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if os.IsNotExist(err) {
return &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
if err != nil {
return err
}
if fi.IsDir {
@ -121,9 +119,7 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
// TODO: use partial error Response on error
fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
if os.IsNotExist(err) {
return nil, &internal.HTTPError{Code: http.StatusNotFound, Err: err}
} else if err != nil {
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,26 +193,40 @@ 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.Context(), 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.Context(), 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 {
@ -224,7 +234,7 @@ func (b *backend) Mkcol(r *http.Request) error {
return internal.HTTPErrorf(http.StatusUnsupportedMediaType, "webdav: request body not supported in MKCOL request")
}
err := b.FileSystem.Mkdir(r.Context(), r.URL.Path)
if os.IsNotExist(err) {
if internal.IsNotFound(err) {
return &internal.HTTPError{Code: http.StatusConflict, Err: err}
}
return err
@ -255,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 {
@ -289,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,6 +5,8 @@ package webdav
import (
"time"
"github.com/emersion/go-webdav/internal"
)
// FileInfo holds information about a WebDAV file.
@ -17,6 +19,11 @@ type FileInfo struct {
ETag string
}
type CreateOptions struct {
IfMatch ConditionalMatch
IfNoneMatch ConditionalMatch
}
type CopyOptions struct {
NoRecursive bool
NoOverwrite bool
@ -25,3 +32,32 @@ type CopyOptions struct {
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
}