From 9a13be12f691ab5d140d2baf8d92c140f477d2c8 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 16:25:49 +0100 Subject: [PATCH 1/7] 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 5819e6dad4d67ef49677bbae1f2d13b5d7657f3d Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 16:26:17 +0100 Subject: [PATCH 2/7] 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 959d789a..a61bf770 100644 --- a/server.go +++ b/server.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/emersion/go-webdav/internal" ) @@ -313,6 +314,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 2da22ddd223135980378d797895a136931fcce42 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Mar 2025 23:11:52 +0100 Subject: [PATCH 3/7] 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 a61bf770..9be1906e 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, From 7aed5ebb96e40edaf60882b99974b8385d554aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Chachu=C5=82a?= Date: Wed, 9 Jul 2025 20:15:09 +0200 Subject: [PATCH 4/7] webdav: add in-memory lock system --- go.mod | 1 + go.sum | 2 + server.go | 87 +++++++++++++++++++++++++++++++++++++++-- server_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 server_test.go diff --git a/go.mod b/go.mod index 0cf5c453..12e6b64a 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.13 require ( github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 + github.com/google/uuid v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index ce086e4e..1b60bc5a 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,7 @@ github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= diff --git a/server.go b/server.go index 9be1906e..b4cfd4a2 100644 --- a/server.go +++ b/server.go @@ -8,8 +8,11 @@ import ( "os" "strconv" "strings" + "sync" "time" + "github.com/google/uuid" + "github.com/emersion/go-webdav/internal" ) @@ -29,6 +32,9 @@ type FileSystem interface { // server. type Handler struct { FileSystem FileSystem + + locks map[string]*internal.Lock + locksMu sync.Mutex } // ServeHTTP implements http.Handler. @@ -38,7 +44,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - b := backend{h.FileSystem} + h.locksMu.Lock() + if h.locks == nil { + h.locks = make(map[string]*internal.Lock) + } + h.locksMu.Unlock() + + b := backend{FileSystem: h.FileSystem, locks: h.locks, locksMu: &h.locksMu} hh := internal.Handler{Backend: &b} hh.ServeHTTP(w, r) } @@ -54,6 +66,9 @@ func NewHTTPError(statusCode int, cause error) error { type backend struct { FileSystem FileSystem + + locks map[string]*internal.Lock + locksMu *sync.Mutex } func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) { @@ -286,7 +301,15 @@ func (b *backend) Delete(r *http.Request) error { IfNoneMatch: ifNoneMatch, IfMatch: ifMatch, } - return b.FileSystem.RemoveAll(r.Context(), r.URL.Path, &opts) + err := b.FileSystem.RemoveAll(r.Context(), r.URL.Path, &opts) + if err == nil { + // URL became unmapped so delete lock if exists + b.locksMu.Lock() + defer b.locksMu.Unlock() + delete(b.locks, r.URL.Path) + } + + return err } func (b *backend) Mkcol(r *http.Request) error { @@ -320,15 +343,71 @@ func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (cr if os.IsExist(err) { return false, &internal.HTTPError{http.StatusPreconditionFailed, err} } + if err == nil { + // URL became unmapped so delete lock if exists + b.locksMu.Lock() + defer b.locksMu.Unlock() + delete(b.locks, r.URL.Path) + } 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") + // TODO: locking unmapped URLs + fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path) + if err != nil { + return nil, false, err + } + if fi.IsDir { + return nil, false, internal.HTTPErrorf(http.StatusBadRequest, "webdav: locking collections is not supported") + } + + if refreshToken != "" { + if lock := b.resourceLock(r.URL.Path); lock == nil || lock.Href != refreshToken { + return nil, false, &internal.HTTPError{Code: http.StatusPreconditionFailed} + } else { + // Lock timeout is not supported so refresh is a no-op + return lock, false, nil + } + } + + token := "opaquelocktoken:" + uuid.NewString() + lock = &internal.Lock{Href: token, Root: fi.Path, Timeout: 0 /* infinity */} + + b.locksMu.Lock() + defer b.locksMu.Unlock() + if _, prs := b.locks[r.URL.Path]; prs { + return nil, false, internal.HTTPErrorf(http.StatusLocked, "webdav: there is already a lock on this resource") + } + b.locks[lock.Root] = lock + + return lock, false, nil } func (b *backend) Unlock(r *http.Request, tokenHref string) error { - return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method") + if lock := b.resourceLock(r.URL.Path); lock == nil { + return internal.HTTPErrorf(http.StatusConflict, "webdav: resource is not locked") + } else if lock.Href != tokenHref { + return internal.HTTPErrorf(http.StatusForbidden, "webdav: incorrect token") + } + + b.locksMu.Lock() + defer b.locksMu.Unlock() + delete(b.locks, r.URL.Path) + + return nil +} + +func (b *backend) resourceLock(path string) *internal.Lock { + b.locksMu.Lock() + defer b.locksMu.Unlock() + + lock, prs := b.locks[path] + if !prs { + return nil + } + + return lock } // BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a diff --git a/server_test.go b/server_test.go new file mode 100644 index 00000000..b98e0b75 --- /dev/null +++ b/server_test.go @@ -0,0 +1,102 @@ +package webdav + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +var lock = ` + + + + + +` + +func TestLock(t *testing.T) { + req := httptest.NewRequest("LOCK", "/res", strings.NewReader(lock)) + req.Header.Set("Content-Type", "application/xml") + w := httptest.NewRecorder() + handler := &Handler{FileSystem: testFileSystem{}} + 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 res.StatusCode != 200 { + t.Errorf("Bad status returned when doing a LOCK:\n%d", res.StatusCode) + } + if !strings.Contains(resp, `/res`) { + t.Errorf("Bad lockroot returned when doing a LOCK, response:\n%s", resp) + } + tok := res.Header.Get("Lock-Token") + if len(tok) < 2 { + t.Error("No token in Lock-Token header when doing a LOCK") + } else if !strings.Contains(resp, tok[1:len(tok)-1]) { + t.Errorf("Token not in body when doing a LOCK, response:\n%s", resp) + } +} + +func TestLockConflict(t *testing.T) { + handler := &Handler{FileSystem: testFileSystem{}} + + req := httptest.NewRequest("LOCK", "/res", strings.NewReader(lock)) + req.Header.Set("Content-Type", "application/xml") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + req = httptest.NewRequest("LOCK", "/res", strings.NewReader(lock)) + req.Header.Set("Content-Type", "application/xml") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusLocked { + t.Errorf("Bad status returned when creating a conflicting lock:\n%d", res.StatusCode) + } +} + +type testFileSystem struct{} + +func (fs testFileSystem) Open(ctx context.Context, name string) (io.ReadCloser, error) { + return nil, nil +} + +func (fs testFileSystem) Stat(ctx context.Context, name string) (*FileInfo, error) { + fi := &FileInfo{Path: name} + return fi, nil +} + +func (fs testFileSystem) ReadDir(ctx context.Context, name string, recursive bool) ([]FileInfo, error) { + return nil, nil +} + +func (fs testFileSystem) Create(ctx context.Context, name string, body io.ReadCloser, opts *CreateOptions) (fileInfo *FileInfo, created bool, err error) { + return nil, false, nil +} + +func (fs testFileSystem) RemoveAll(ctx context.Context, name string, opts *RemoveAllOptions) error { + return nil +} + +func (fs testFileSystem) Mkdir(ctx context.Context, name string) error { + return nil +} + +func (fs testFileSystem) Copy(ctx context.Context, name, dest string, options *CopyOptions) (created bool, err error) { + return false, nil +} + +func (fs testFileSystem) Move(ctx context.Context, name, dest string, options *MoveOptions) (created bool, err error) { + return false, nil +} From faf15483f582caabf9c5f984ff03d69a68ded238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Chachu=C5=82a?= Date: Thu, 10 Jul 2025 20:16:54 +0200 Subject: [PATCH 5/7] internal: fix marshaling of Timeout and Depth Without implementing MarshalText, these elements are marshaled as integers, so no "Second-xx" or "infinity". --- internal/internal.go | 8 ++++++++ server_test.go | 3 +++ 2 files changed, 11 insertions(+) diff --git a/internal/internal.go b/internal/internal.go index 1ab9c2e1..c6d7d36e 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -51,6 +51,10 @@ func (d Depth) String() string { panic("webdav: invalid Depth value") } +func (d Depth) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + // ParseOverwrite parses an Overwrite header. func ParseOverwrite(s string) (bool, error) { switch s { @@ -96,6 +100,10 @@ func (t Timeout) String() string { return fmt.Sprintf("Second-%d", t.Duration/time.Second) } +func (t Timeout) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + func ParseLockToken(s string) (string, error) { if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") { return "", fmt.Errorf("webdav: invalid Lock-Token value") diff --git a/server_test.go b/server_test.go index b98e0b75..3f00e4a4 100644 --- a/server_test.go +++ b/server_test.go @@ -38,6 +38,9 @@ func TestLock(t *testing.T) { if !strings.Contains(resp, `/res`) { t.Errorf("Bad lockroot returned when doing a LOCK, response:\n%s", resp) } + if !strings.Contains(resp, `infinity`) { + t.Errorf("Bad depth returned when doing a LOCK, response:\n%s", resp) + } tok := res.Header.Get("Lock-Token") if len(tok) < 2 { t.Error("No token in Lock-Token header when doing a LOCK") From 5b1721fa2b8e7381d0c0d9fd6fb3c1e7ef28d20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Chachu=C5=82a?= Date: Mon, 14 Jul 2025 18:59:31 +0200 Subject: [PATCH 6/7] internal: fix checking body of lock refresh requests Lock refresh requests have an empty body, so we shouldn't return error on empty lockscope or locktype. --- internal/server.go | 13 ++++++------- server_test.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/server.go b/internal/server.go index ac9ad973..49190956 100644 --- a/internal/server.go +++ b/internal/server.go @@ -344,6 +344,12 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) error { if err := DecodeXMLRequest(r, &lockInfo); err != nil { return err } + 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") + } } else { if err := ensureRequestBodyEmpty(r); err != nil { return err @@ -358,13 +364,6 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) error { 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 diff --git a/server_test.go b/server_test.go index 3f00e4a4..132ed625 100644 --- a/server_test.go +++ b/server_test.go @@ -69,6 +69,20 @@ func TestLockConflict(t *testing.T) { } } +func TestLockRefreshBadToken(t *testing.T) { + req := httptest.NewRequest("LOCK", "/res", strings.NewReader("")) + req.Header.Set("If", "()") + w := httptest.NewRecorder() + handler := &Handler{FileSystem: testFileSystem{}} + handler.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusPreconditionFailed { + t.Errorf("Bad status returned when refreshing a lock with bad token:\n%d", res.StatusCode) + } +} + type testFileSystem struct{} func (fs testFileSystem) Open(ctx context.Context, name string) (io.ReadCloser, error) { From 0d1b45ce037541db31d1e4c7e1c4683114583320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Chachu=C5=82a?= Date: Sun, 13 Jul 2025 02:02:47 +0200 Subject: [PATCH 7/7] webdav: check locks before write operations --- internal/internal.go | 27 ++++++++++++++++++++ internal/server.go | 11 ++++---- server.go | 61 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/internal/internal.go b/internal/internal.go index c6d7d36e..5f92d490 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -115,6 +115,33 @@ func FormatLockToken(token string) string { return fmt.Sprintf("<%v>", token) } +func ParseSubmittedToken(h http.Header) (string, error) { + hif := h.Get("If") + if hif == "" { + return "", nil + } + + conditions, err := ParseConditions(hif) + if err != nil { + return "", &HTTPError{http.StatusBadRequest, err} + } + + if len(conditions) == 0 { + return "", nil + } + if len(conditions) > 1 { + return "", HTTPErrorf(http.StatusBadRequest, "webdav: multiple lists are not supported in the If header field") + } + if len(conditions[0]) == 0 { + return "", nil + } + if len(conditions[0]) > 1 { + return "", HTTPErrorf(http.StatusBadRequest, "webdav: multiple conditions are not supported in the If header field") + } + + return conditions[0][0].Token, nil +} + // Condition is a condition to match lock tokens and entity tags. // // Only one of Token or ETag is set. diff --git a/internal/server.go b/internal/server.go index 49190956..f7e3a208 100644 --- a/internal/server.go +++ b/internal/server.go @@ -355,13 +355,14 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) error { return err } - conditions, err := ParseConditions(r.Header.Get("If")) + var err error + refreshToken, err = ParseSubmittedToken(r.Header) 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") + return err + } + if refreshToken == "" { + return HTTPErrorf(http.StatusBadRequest, "webdav: a lock token must be specified in the If header field") } - refreshToken = conditions[0][0].Token } depth := DepthInfinity diff --git a/server.go b/server.go index b4cfd4a2..ffc2f0b9 100644 --- a/server.go +++ b/server.go @@ -262,6 +262,16 @@ func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (* } func (b *backend) Put(w http.ResponseWriter, r *http.Request) error { + if lock := b.resourceLock(r.URL.Path); lock != nil { + token, err := internal.ParseSubmittedToken(r.Header) + if err != nil { + return err + } + if token != lock.Href { + return &internal.HTTPError{Code: http.StatusLocked} + } + } + ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match")) ifMatch := ConditionalMatch(r.Header.Get("If-Match")) @@ -294,6 +304,16 @@ func (b *backend) Put(w http.ResponseWriter, r *http.Request) error { } func (b *backend) Delete(r *http.Request) error { + if lock := b.resourceLock(r.URL.Path); lock != nil { + token, err := internal.ParseSubmittedToken(r.Header) + if err != nil { + return err + } + if token != lock.Href { + return &internal.HTTPError{Code: http.StatusLocked} + } + } + ifNoneMatch := ConditionalMatch(r.Header.Get("If-None-Match")) ifMatch := ConditionalMatch(r.Header.Get("If-Match")) @@ -324,6 +344,16 @@ func (b *backend) Mkcol(r *http.Request) error { } func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) { + if lock := b.resourceLock(dest.Path); lock != nil { + token, err := internal.ParseSubmittedToken(r.Header) + if err != nil { + return false, err + } + if token != lock.Href { + return false, &internal.HTTPError{Code: http.StatusLocked} + } + } + options := CopyOptions{ NoRecursive: !recursive, NoOverwrite: !overwrite, @@ -336,6 +366,37 @@ 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) { + // Check source and destination locks + var conditions [][]internal.Condition + hif := r.Header.Get("If") + if hif == "" { + conditions = nil + } else { + var err error + conditions, err = internal.ParseConditions(hif) + if err != nil { + return false, &internal.HTTPError{http.StatusBadRequest, err} + } + } + srcLock := b.resourceLock(r.URL.Path) + destLock := b.resourceLock(dest.Path) + for _, conds := range conditions { + if len(conds) == 0 { + continue + } + if len(conds) > 1 { + return false, internal.HTTPErrorf(http.StatusBadRequest, "webdav: multiple conditions are not supported in the If header field") + } + if (conds[0].Resource == "" || conds[0].Resource == r.URL.Path) && srcLock != nil && conds[0].Token == srcLock.Href { + srcLock = nil + } else if (conds[0].Resource == dest.Path) && destLock != nil && conds[0].Token == destLock.Href { + destLock = nil + } + } + if srcLock != nil || destLock != nil { + return false, &internal.HTTPError{Code: http.StatusLocked} + } + options := MoveOptions{ NoOverwrite: !overwrite, }