From 73d296b2ed8ba6d7ef29f5c9de993713b0f598d3 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 16:25:49 +0100 Subject: [PATCH 1/3] internal: add parser for Timeout, Lock-Token and If header fields --- internal/internal.go | 173 ++++++++++++++++++++++++++++++++++++++ internal/internal_test.go | 99 ++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 internal/internal_test.go diff --git a/internal/internal.go b/internal/internal.go index a0867ecd..1ab9c2e1 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" + "time" ) // Depth indicates whether a request applies to the resource's members. It's @@ -68,6 +71,176 @@ func FormatOverwrite(overwrite bool) string { } } +type Timeout struct { + Duration time.Duration +} + +func ParseTimeout(s string) (Timeout, error) { + if s == "Infinite" { + return Timeout{}, nil + } else if strings.HasPrefix(s, "Second-") { + n, err := strconv.Atoi(strings.TrimPrefix(s, "Second-")) + if err != nil || n <= 0 { + return Timeout{}, fmt.Errorf("webdav: invalid Timeout value") + } + return Timeout{Duration: time.Duration(n) * time.Second}, nil + } else { + return Timeout{}, fmt.Errorf("webdav: invalid Timeout value") + } +} + +func (t Timeout) String() string { + if t.Duration == 0 { + return "Infinite" + } + return fmt.Sprintf("Second-%d", t.Duration/time.Second) +} + +func ParseLockToken(s string) (string, error) { + if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") { + return "", fmt.Errorf("webdav: invalid Lock-Token value") + } + return s[1 : len(s)-1], nil +} + +func FormatLockToken(token string) string { + return fmt.Sprintf("<%v>", token) +} + +// Condition is a condition to match lock tokens and entity tags. +// +// Only one of Token or ETag is set. +type Condition struct { + Resource string + Not bool + Token string + ETag string +} + +type conditionParser struct { + s string +} + +func (p *conditionParser) acceptByte(ch byte) bool { + if len(p.s) == 0 || p.s[0] != ch { + return false + } + p.s = p.s[1:] + return true +} + +func (p *conditionParser) expectByte(ch byte) error { + if len(p.s) == 0 { + return fmt.Errorf("webdav: invalid If value: expected %q, got EOF", ch) + } else if p.s[0] != ch { + return fmt.Errorf("webdav: invalid If value: expected %q, got %q", ch, p.s[0]) + } + p.s = p.s[1:] + return nil +} + +func (p *conditionParser) lws() bool { + lws := false + for p.acceptByte(' ') || p.acceptByte('\t') { + lws = true + } + return lws +} + +func (p *conditionParser) consumeUntilByte(ch byte) (string, error) { + i := strings.IndexByte(p.s, ch) + if i < 0 { + return "", fmt.Errorf("webdav: invalid If value: expected %q, got EOF", ch) + } + s := p.s[:i] + p.s = p.s[i+1:] + return s, nil +} + +func (p *conditionParser) condition() (*Condition, error) { + not := strings.HasPrefix(p.s, "Not") + if not { + p.s = strings.TrimPrefix(p.s, "Not") + p.lws() + } + + if p.acceptByte('<') { + token, err := p.consumeUntilByte('>') + if err != nil { + return nil, err + } + return &Condition{Not: not, Token: token}, nil + } else if p.acceptByte('[') { + etag, err := p.consumeUntilByte(']') + if err != nil { + return nil, err + } + return &Condition{Not: not, ETag: etag}, nil + } else { + return nil, fmt.Errorf("webdav: invalid If value: invalid condition") + } +} + +func (p *conditionParser) list() ([]Condition, error) { + if err := p.expectByte('('); err != nil { + return nil, err + } + p.lws() + + var l []Condition + for !p.acceptByte(')') { + cond, err := p.condition() + if err != nil { + return nil, err + } + l = append(l, *cond) + p.lws() + } + + return l, nil +} + +func (p *conditionParser) parse() ([][]Condition, error) { + var conditions [][]Condition + for { + p.lws() + if p.s == "" { + break + } + + var resource string + if p.acceptByte('<') { + var err error + resource, err = p.consumeUntilByte('>') + if err != nil { + return nil, err + } + p.lws() + } + + l, err := p.list() + if err != nil { + return nil, err + } + + for i := range l { + l[i].Resource = resource + } + + conditions = append(conditions, l) + } + + if len(conditions) == 0 { + return nil, fmt.Errorf("webdav: invalid If value: empty list") + } + return conditions, nil +} + +func ParseConditions(s string) ([][]Condition, error) { + p := conditionParser{s} + return p.parse() +} + type HTTPError struct { Code int Err error diff --git a/internal/internal_test.go b/internal/internal_test.go new file mode 100644 index 00000000..c49dbdbe --- /dev/null +++ b/internal/internal_test.go @@ -0,0 +1,99 @@ +package internal + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseConditions(t *testing.T) { + tests := []struct { + name string + s string + conditions [][]Condition + }{ + { + name: "RFC 4918 section 10.4.6: No-tag Production", + s: `( + ["I am an ETag"]) + (["I am another ETag"])`, + conditions: [][]Condition{ + { + {Token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2"}, + {ETag: `"I am an ETag"`}, + }, + { + {ETag: `"I am another ETag"`}, + }, + }, + }, + { + name: `RFC 4918 section 10.4.7: Using "Not" with No-tag Production`, + s: `(Not + )`, + conditions: [][]Condition{ + { + {Not: true, Token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2"}, + {Token: "urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092"}, + }, + }, + }, + { + name: "RFC 4918 section 10.4.8: Causing a Condition to Always Evaluate to True", + s: `() + (Not )`, + conditions: [][]Condition{ + { + {Token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2"}, + }, + { + {Not: true, Token: "DAV:no-lock"}, + }, + }, + }, + { + name: "RFC 4918 section 10.4.9: Tagged List If Header in COPY", + s: ` + ( + [W/"A weak ETag"]) (["strong ETag"])`, + conditions: [][]Condition{ + { + {Resource: "/resource1", Token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2"}, + {Resource: "/resource1", ETag: `W/"A weak ETag"`}, + }, + { + {ETag: `"strong ETag"`}, + }, + }, + }, + { + name: "RFC 4918 section 10.4.10: Matching Lock Tokens with Collection Locks", + s: ` + ()`, + conditions: [][]Condition{ + { + {Resource: "http://www.example.com/specs/", Token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2"}, + }, + }, + }, + { + name: "RFC 4918 section 10.4.11: Matching ETags on Unmapped URLs", + s: ` (["4217"])`, + conditions: [][]Condition{ + { + {Resource: "/specs/rfc2518.doc", ETag: `"4217"`}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + l, err := ParseConditions(strings.ReplaceAll(tc.s, "\n", " ")) + if err != nil { + t.Fatalf("ParseConditions() = %v", err) + } else if !reflect.DeepEqual(l, tc.conditions) { + t.Errorf("ParseConditions() = \n %#v \n but want: \n %#v", l, tc.conditions) + } + }) + } +} From c18dfc3126dbda8a6abe553966ea5226bed9a5ee Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 16:26:17 +0100 Subject: [PATCH 2/3] Decode and encode LOCK and UNLOCK requests --- caldav/server.go | 8 +++ carddav/server.go | 9 +++ internal/elements.go | 71 +++++++++++++++++++++++ internal/server.go | 130 ++++++++++++++++++++++++++++++++++++++++++- server.go | 9 +++ 5 files changed, 224 insertions(+), 3 deletions(-) diff --git a/caldav/server.go b/caldav/server.go index 7ddfffcb..dcc7d4f0 100644 --- a/caldav/server.go +++ b/caldav/server.go @@ -741,6 +741,14 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr return false, internal.HTTPErrorf(http.StatusNotImplemented, "caldav: Move not implemented") } +func (b *backend) Lock(r *http.Request, depth internal.Depth, timeout time.Duration, refreshToken string) (lock *internal.Lock, created bool, err error) { + return nil, false, internal.HTTPErrorf(http.StatusMethodNotAllowed, "caldav: unsupported method") +} + +func (b *backend) Unlock(r *http.Request, tokenHref string) error { + return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") +} + // https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1 type PreconditionType string diff --git a/carddav/server.go b/carddav/server.go index 8e96ed05..fd7f2c3a 100644 --- a/carddav/server.go +++ b/carddav/server.go @@ -10,6 +10,7 @@ import ( "path" "strconv" "strings" + "time" "github.com/emersion/go-vcard" "github.com/emersion/go-webdav" @@ -733,6 +734,14 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr return false, internal.HTTPErrorf(http.StatusNotImplemented, "carddav: Move not implemented") } +func (b *backend) Lock(r *http.Request, depth internal.Depth, timeout time.Duration, refreshToken string) (lock *internal.Lock, created bool, err error) { + return nil, false, internal.HTTPErrorf(http.StatusMethodNotAllowed, "carddav: unsupported method") +} + +func (b *backend) Unlock(r *http.Request, tokenHref string) error { + return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") +} + // PreconditionType as defined in https://tools.ietf.org/rfcmarkup?doc=6352#section-6.3.2.1 type PreconditionType string diff --git a/internal/elements.go b/internal/elements.go index db7d9603..da635cbf 100644 --- a/internal/elements.go +++ b/internal/elements.go @@ -20,6 +20,7 @@ var ( GetContentTypeName = xml.Name{Namespace, "getcontenttype"} GetLastModifiedName = xml.Name{Namespace, "getlastmodified"} GetETagName = xml.Name{Namespace, "getetag"} + SupportedLockName = xml.Name{Namespace, "supportedlock"} CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"} ) @@ -346,6 +347,19 @@ type GetContentType struct { Type string `xml:",chardata"` } +// https://www.rfc-editor.org/rfc/rfc4918#section-15.10 +type SupportedLock struct { + XMLName xml.Name `xml:"DAV: supportedlock"` + LockEntries []LockEntry `xml:"lockentry"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.10 +type LockEntry struct { + XMLName xml.Name `xml:"DAV: lockentry"` + LockScope LockScope `xml:"lockscope"` + LockType LockType `xml:"locktype"` +} + type Time time.Time func (t *Time) UnmarshalText(b []byte) error { @@ -450,3 +464,60 @@ type Limit struct { XMLName xml.Name `xml:"DAV: limit"` NResults uint `xml:"nresults"` } + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.11 +type LockInfo struct { + XMLName xml.Name `xml:"DAV: lockinfo"` + LockScope LockScope `xml:"lockscope"` + LockType LockType `xml:"locktype"` + Owner *Owner `xml:"owner,omitempty"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.13 +type LockScope struct { + XMLName xml.Name `xml:"DAV: lockscope"` + Exclusive *struct{} `xml:"exclusive"` + Shared *struct{} `xml:"shared"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.15 +type LockType struct { + XMLName xml.Name `xml:"DAV: locktype"` + Write *struct{} `xml:"write"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.17 +type Owner struct { + XMLName xml.Name `xml:"DAV: owner"` + // TODO +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-15.8 +type LockDiscovery struct { + XMLName xml.Name `xml:"DAV: lockdiscovery"` + ActiveLock []ActiveLock `xml:"activelock,omitempty"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.1 +type ActiveLock struct { + XMLName xml.Name `xml:"DAV: activelock"` + LockScope LockScope `xml:"lockscope"` + LockType LockType `xml:"locktype"` + Depth Depth `xml:"depth"` + Owner *Owner `xml:"owner,omitempty"` + Timeout *Timeout `xml:"timeout,omitempty"` + LockToken *LockToken `xml:"locktoken,omitempty"` + LockRoot LockRoot `xml:"lockroot"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.14 +type LockToken struct { + XMLName xml.Name `xml:"DAV: locktoken"` + Href string `xml:"href"` +} + +// https://www.rfc-editor.org/rfc/rfc4918#section-14.12 +type LockRoot struct { + XMLName xml.Name `xml:"DAV: lockroot"` + Href string `xml:"href"` +} diff --git a/internal/server.go b/internal/server.go index b76443dd..ac9ad973 100644 --- a/internal/server.go +++ b/internal/server.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "time" ) func ServeError(w http.ResponseWriter, err error) { @@ -33,6 +34,14 @@ func isContentXML(h http.Header) bool { return t == "application/xml" || t == "text/xml" } +func ensureRequestBodyEmpty(r *http.Request) error { + var b [1]byte + if _, err := r.Body.Read(b[:]); err != io.EOF { + return HTTPErrorf(http.StatusBadRequest, "webdav: unsupported request body") + } + return nil +} + func DecodeXMLRequest(r *http.Request, v interface{}) error { if !isContentXML(r.Header) { return HTTPErrorf(http.StatusBadRequest, "webdav: expected application/xml request") @@ -71,6 +80,14 @@ type Backend interface { Mkcol(r *http.Request) error Copy(r *http.Request, dest *Href, recursive, overwrite bool) (created bool, err error) Move(r *http.Request, dest *Href, overwrite bool) (created bool, err error) + Lock(r *http.Request, depth Depth, timeout time.Duration, refreshToken string) (lock *Lock, created bool, err error) + Unlock(r *http.Request, tokenHref string) error +} + +type Lock struct { + Href string + Root string + Timeout time.Duration } type Handler struct { @@ -106,6 +123,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } case "COPY", "MOVE": err = h.handleCopyMove(w, r) + case "LOCK": + err = h.handleLock(w, r) + case "UNLOCK": + err = h.handleUnlock(w, r) default: err = HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") } @@ -136,9 +157,8 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) error { return err } } else { - var b [1]byte - if _, err := r.Body.Read(b[:]); err != io.EOF { - return HTTPErrorf(http.StatusBadRequest, "webdav: unsupported request body") + if err := ensureRequestBodyEmpty(r); err != nil { + return err } propfind.AllProp = &struct{}{} } @@ -314,3 +334,107 @@ func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) error { } return nil } + +func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) error { + var ( + lockInfo LockInfo + refreshToken string + ) + if isContentXML(r.Header) { + if err := DecodeXMLRequest(r, &lockInfo); err != nil { + return err + } + } else { + if err := ensureRequestBodyEmpty(r); err != nil { + return err + } + + conditions, err := ParseConditions(r.Header.Get("If")) + if err != nil { + return &HTTPError{http.StatusBadRequest, err} + } else if len(conditions) != 1 || len(conditions[0]) != 1 || conditions[0][0].Token == "" { + return HTTPErrorf(http.StatusBadRequest, "webdav: a single lock token must be specified in the If header field") + } + refreshToken = conditions[0][0].Token + } + + if lockInfo.LockScope.Exclusive == nil || lockInfo.LockScope.Shared != nil { + return HTTPErrorf(http.StatusBadRequest, "webdav: only exclusive locks are supported") + } + if lockInfo.LockType.Write == nil { + return HTTPErrorf(http.StatusBadRequest, "webdav: only write locks are supported") + } + + depth := DepthInfinity + if s := r.Header.Get("Depth"); s != "" { + var err error + depth, err = ParseDepth(s) + if err != nil { + return &HTTPError{http.StatusBadRequest, err} + } + } + + var timeout time.Duration + if s := r.Header.Get("Timeout"); s != "" { + t, err := ParseTimeout(s) + if err != nil { + return &HTTPError{http.StatusBadRequest, err} + } + timeout = t.Duration + } + + lock, created, err := h.Backend.Lock(r, depth, timeout, refreshToken) + if err != nil { + return err + } + + var t *Timeout + if lock.Timeout != 0 { + t = &Timeout{Duration: lock.Timeout} + } + + lockDiscovery := &LockDiscovery{ + ActiveLock: []ActiveLock{ + { + LockScope: LockScope{ + Exclusive: &struct{}{}, + }, + LockType: LockType{ + Write: &struct{}{}, + }, + Depth: depth, + Timeout: t, + LockToken: &LockToken{Href: lock.Href}, + LockRoot: LockRoot{Href: lock.Root}, + }, + }, + } + prop, err := EncodeProp(lockDiscovery) + if err != nil { + return err + } + + if refreshToken == "" { + w.Header().Set("Lock-Token", FormatLockToken(lock.Href)) + } + if created { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusOK) + } + return ServeXML(w).Encode(prop) +} + +func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) error { + tokenHref, err := ParseLockToken(r.Header.Get("Lock-Token")) + if err != nil { + return &HTTPError{http.StatusBadRequest, err} + } + + if err := h.Backend.Unlock(r, tokenHref); err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/server.go b/server.go index 1b18be0d..fcb8c41f 100644 --- a/server.go +++ b/server.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/emersion/go-webdav/internal" ) @@ -270,6 +271,14 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr return created, err } +func (b *backend) Lock(r *http.Request, depth internal.Depth, timeout time.Duration, refreshToken string) (lock *internal.Lock, created bool, err error) { + return nil, false, internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") +} + +func (b *backend) Unlock(r *http.Request, tokenHref string) error { + return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") +} + // 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 From e933509518d29927d6b0a78abfa9d2f02db97cb8 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 23:11:52 +0100 Subject: [PATCH 3/3] webdav: advertise support for DAV class 2 --- server.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server.go b/server.go index fcb8c41f..48f96d11 100644 --- a/server.go +++ b/server.go @@ -57,9 +57,11 @@ type backend struct { } func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { + caps = []string{"2"} + fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path) if internal.IsNotFound(err) { - return nil, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil + return caps, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil } else if err != nil { return nil, nil, err } @@ -76,7 +78,7 @@ func (b *backend) Options(r *http.Request) (caps []string, allow []string, err e allow = append(allow, http.MethodHead, http.MethodGet, http.MethodPut) } - return nil, allow, nil + return caps, allow, nil } func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error { @@ -162,6 +164,13 @@ func (b *backend) propFindFile(propfind *internal.PropFind, fi *FileInfo) (*inte return internal.NewResourceType(types...), nil } + props[internal.SupportedLockName] = internal.PropFindValue(&internal.SupportedLock{ + LockEntries: []internal.LockEntry{{ + LockScope: internal.LockScope{Exclusive: &struct{}{}}, + LockType: internal.LockType{Write: &struct{}{}}, + }}, + }) + if !fi.IsDir { props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{ Length: fi.Size,