go-webdav icon indicating copy to clipboard operation
go-webdav copied to clipboard

webdav: add support for locks

Open emersion opened this issue 8 months ago • 1 comments

  • [x] Decode/encode LOCK/UNLOCK requests
  • [x] Decode If header field
  • [ ] Plumb all relevant endpoints
  • [ ] Add in-memory lock system implementation

emersion avatar Mar 12 '25 22:03 emersion

Hi, I've done some work based on this branch, adding a memory-based Lock implementation and enabling Lock support in other parts of the code. This work allowed me to connect to the sample server via Mac's Finder, but I haven't done further testing.
I really like this library, so I've been looking forward to this PR for a long time, hoping to use it for my scenario.
Here's my patch, and I hope it helps advance the work on this branch.

Subject: [PATCH] Add in-memory WebDAV locking system with support for LOCK/UNLOCK
---
Index: caldav/server.go
===================================================================
diff --git a/caldav/server.go b/caldav/server.go
--- a/caldav/server.go	(revision e933509518d29927d6b0a78abfa9d2f02db97cb8)
+++ b/caldav/server.go	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
@@ -317,26 +317,39 @@
 func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
 	caps = []string{"calendar-access"}
 
+	// Add lock capability if global lock system is available
+	if webdav.GetGlobalLockSystem() != nil {
+		caps = append(caps, "1")
+	}
+
+	var methods []string
 	if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendarObject {
-		return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil
-	}
-
-	var dataReq CalendarCompRequest
-	_, err = b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
-	if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
-		return caps, []string{http.MethodOptions, http.MethodPut}, nil
-	} else if err != nil {
-		return nil, nil, err
-	}
-
-	return caps, []string{
-		http.MethodOptions,
-		http.MethodHead,
-		http.MethodGet,
-		http.MethodPut,
-		http.MethodDelete,
-		"PROPFIND",
-	}, nil
+		methods = []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}
+	} else {
+		var dataReq CalendarCompRequest
+		_, err = b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
+		if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
+			methods = []string{http.MethodOptions, http.MethodPut}
+		} else if err != nil {
+			return nil, nil, err
+		} else {
+			methods = []string{
+				http.MethodOptions,
+				http.MethodHead,
+				http.MethodGet,
+				http.MethodPut,
+				http.MethodDelete,
+				"PROPFIND",
+			}
+		}
+	}
+
+	// Add lock methods if global lock system is available
+	if webdav.GetGlobalLockSystem() != nil {
+		methods = append(methods, "LOCK", "UNLOCK")
+	}
+
+	return caps, methods, nil
 }
 
 func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
@@ -742,11 +755,13 @@
 }
 
 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")
+	// Use the global lock system
+	return webdav.GetGlobalLockSystem().Lock(r, depth, timeout, refreshToken)
 }
 
 func (b *backend) Unlock(r *http.Request, tokenHref string) error {
-	return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method")
+	// Use the global lock system
+	return webdav.GetGlobalLockSystem().Unlock(r, tokenHref)
 }
 
 // https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
Index: carddav/server.go
===================================================================
diff --git a/carddav/server.go b/carddav/server.go
--- a/carddav/server.go	(revision e933509518d29927d6b0a78abfa9d2f02db97cb8)
+++ b/carddav/server.go	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
@@ -282,28 +282,41 @@
 func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
 	caps = []string{"addressbook"}
 
+	// Add lock capability if global lock system is available
+	if webdav.GetGlobalLockSystem() != nil {
+		caps = append(caps, "1")
+	}
+
+	var methods []string
 	if b.resourceTypeAtPath(r.URL.Path) != resourceTypeAddressObject {
 		// Note: some clients assume the address book is read-only when
 		// DELETE/MKCOL are missing
-		return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil
-	}
-
-	var dataReq AddressDataRequest
-	_, err = b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
-	if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
-		return caps, []string{http.MethodOptions, http.MethodPut}, nil
-	} else if err != nil {
-		return nil, nil, err
-	}
-
-	return caps, []string{
-		http.MethodOptions,
-		http.MethodHead,
-		http.MethodGet,
-		http.MethodPut,
-		http.MethodDelete,
-		"PROPFIND",
-	}, nil
+		methods = []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}
+	} else {
+		var dataReq AddressDataRequest
+		_, err = b.Backend.GetAddressObject(r.Context(), r.URL.Path, &dataReq)
+		if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
+			methods = []string{http.MethodOptions, http.MethodPut}
+		} else if err != nil {
+			return nil, nil, err
+		} else {
+			methods = []string{
+				http.MethodOptions,
+				http.MethodHead,
+				http.MethodGet,
+				http.MethodPut,
+				http.MethodDelete,
+				"PROPFIND",
+			}
+		}
+	}
+
+	// Add lock methods if global lock system is available
+	if webdav.GetGlobalLockSystem() != nil {
+		methods = append(methods, "LOCK", "UNLOCK")
+	}
+
+	return caps, methods, nil
 }
 
 func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
@@ -735,11 +748,13 @@
 }
 
 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")
+	// Use the global lock system
+	return webdav.GetGlobalLockSystem().Lock(r, depth, timeout, refreshToken)
 }
 
 func (b *backend) Unlock(r *http.Request, tokenHref string) error {
-	return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method")
+	// Use the global lock system
+	return webdav.GetGlobalLockSystem().Unlock(r, tokenHref)
 }
 
 // PreconditionType as defined in https://tools.ietf.org/rfcmarkup?doc=6352#section-6.3.2.1
Index: internal/elements.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/internal/elements.go b/internal/elements.go
--- a/internal/elements.go	(revision e933509518d29927d6b0a78abfa9d2f02db97cb8)
+++ b/internal/elements.go	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
@@ -21,6 +21,7 @@
 	GetLastModifiedName  = xml.Name{Namespace, "getlastmodified"}
 	GetETagName          = xml.Name{Namespace, "getetag"}
 	SupportedLockName    = xml.Name{Namespace, "supportedlock"}
+	LockDiscoveryName    = xml.Name{Namespace, "lockdiscovery"}
 
 	CurrentUserPrincipalName = xml.Name{Namespace, "current-user-principal"}
 )
Index: locks.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/locks.go b/locks.go
new file mode 100644
--- /dev/null	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
+++ b/locks.go	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
@@ -0,0 +1,168 @@
+package webdav
+
+import (
+	"fmt"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/emersion/go-webdav/internal"
+)
+
+// LockSystem provides an in-memory implementation of WebDAV locks.
+type LockSystem struct {
+	mu    sync.RWMutex
+	locks map[string]*lockInfo // Map of token -> lock info
+	paths map[string][]string  // Map of path -> tokens
+}
+
+// lockInfo contains information about an active lock.
+type lockInfo struct {
+	Token   string
+	Root    string
+	Created time.Time
+	Timeout time.Duration
+}
+
+// Global lock system that can be used by all backends
+var globalLockSystem *LockSystem
+
+// NewLockSystem creates a new in-memory lock system.
+func NewLockSystem() *LockSystem {
+	return &LockSystem{
+		locks: make(map[string]*lockInfo),
+		paths: make(map[string][]string),
+	}
+}
+
+// GetGlobalLockSystem returns the global lock system, creating it if necessary.
+func GetGlobalLockSystem() *LockSystem {
+	if globalLockSystem == nil {
+		globalLockSystem = NewLockSystem()
+	}
+	return globalLockSystem
+}
+
+// Lock creates or refreshes a lock.
+func (ls *LockSystem) Lock(r *http.Request, depth internal.Depth, timeout time.Duration, refreshToken string) (*internal.Lock, bool, error) {
+	ls.mu.Lock()
+	defer ls.mu.Unlock()
+
+	path := r.URL.Path
+
+	// If refreshToken is provided, refresh the existing lock
+	if refreshToken != "" {
+		lock, ok := ls.locks[refreshToken]
+		if !ok {
+			return nil, false, internal.HTTPErrorf(http.StatusPreconditionFailed, "webdav: lock token not found")
+		}
+
+		// Update the timeout
+		lock.Timeout = timeout
+		lock.Created = time.Now()
+
+		return &internal.Lock{
+			Href:    lock.Token,
+			Root:    lock.Root,
+			Timeout: lock.Timeout,
+		}, false, nil
+	}
+
+	// Check if the path is already locked
+	if tokens, ok := ls.paths[path]; ok && len(tokens) > 0 {
+		return nil, false, internal.HTTPErrorf(http.StatusLocked, "webdav: path already locked")
+	}
+
+	// Create a new lock
+	token := generateToken()
+	lock := &lockInfo{
+		Token:   token,
+		Root:    path,
+		Created: time.Now(),
+		Timeout: timeout,
+	}
+
+	// Store the lock
+	ls.locks[token] = lock
+	ls.paths[path] = append(ls.paths[path], token)
+
+	return &internal.Lock{
+		Href:    token,
+		Root:    path,
+		Timeout: timeout,
+	}, true, nil
+}
+
+// Unlock removes a lock.
+func (ls *LockSystem) Unlock(r *http.Request, tokenHref string) error {
+	ls.mu.Lock()
+	defer ls.mu.Unlock()
+
+	lock, ok := ls.locks[tokenHref]
+	if !ok {
+		return internal.HTTPErrorf(http.StatusPreconditionFailed, "webdav: lock token not found")
+	}
+
+	// Remove the lock from the paths map
+	path := lock.Root
+	tokens := ls.paths[path]
+	for i, t := range tokens {
+		if t == tokenHref {
+			// Remove the token from the slice
+			ls.paths[path] = append(tokens[:i], tokens[i+1:]...)
+			break
+		}
+	}
+
+	// If the path has no more locks, remove it from the map
+	if len(ls.paths[path]) == 0 {
+		delete(ls.paths, path)
+	}
+
+	// Remove the lock from the locks map
+	delete(ls.locks, tokenHref)
+
+	return nil
+}
+
+// CleanExpiredLocks removes expired locks.
+func (ls *LockSystem) CleanExpiredLocks() {
+	ls.mu.Lock()
+	defer ls.mu.Unlock()
+
+	now := time.Now()
+	for token, lock := range ls.locks {
+		// Skip infinite locks
+		if lock.Timeout == 0 {
+			continue
+		}
+
+		// Check if the lock has expired
+		if now.Sub(lock.Created) > lock.Timeout {
+			// Remove the lock from the paths map
+			path := lock.Root
+			tokens := ls.paths[path]
+			for i, t := range tokens {
+				if t == token {
+					// Remove the token from the slice
+					ls.paths[path] = append(tokens[:i], tokens[i+1:]...)
+					break
+				}
+			}
+
+			// If the path has no more locks, remove it from the map
+			if len(ls.paths[path]) == 0 {
+				delete(ls.paths, path)
+			}
+
+			// Remove the lock from the locks map
+			delete(ls.locks, token)
+		}
+	}
+}
+
+// generateToken creates a unique token for a lock.
+func generateToken() string {
+	// Create a simple unique token using timestamp and random number
+	return fmt.Sprintf("opaquelocktoken:%d-%d", time.Now().UnixNano(), time.Now().Unix())
+}
Index: server.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/server.go b/server.go
--- a/server.go	(revision e933509518d29927d6b0a78abfa9d2f02db97cb8)
+++ b/server.go	(revision 72a787ada657475be77b0efd5c5bc9cdced67961)
@@ -29,6 +29,7 @@
 // server.
 type Handler struct {
 	FileSystem FileSystem
+	LockSystem *LockSystem
 }
 
 // ServeHTTP implements http.Handler.
@@ -38,7 +39,15 @@
 		return
 	}
 
-	b := backend{h.FileSystem}
+	// Use the global lock system if not provided
+	if h.LockSystem == nil {
+		h.LockSystem = GetGlobalLockSystem()
+	}
+
+	b := backend{
+		FileSystem: h.FileSystem,
+		LockSystem: h.LockSystem,
+	}
 	hh := internal.Handler{Backend: &b}
 	hh.ServeHTTP(w, r)
 }
@@ -54,14 +63,23 @@
 
 type backend struct {
 	FileSystem FileSystem
+	LockSystem *LockSystem
 }
 
 func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
+	// Add lock capability if lock system is available
 	caps = []string{"2"}
+	if b.LockSystem != nil {
+		caps = append(caps, "1")
+	}
 
 	fi, err := b.FileSystem.Stat(r.Context(), r.URL.Path)
 	if internal.IsNotFound(err) {
-		return caps, []string{http.MethodOptions, http.MethodPut, "MKCOL"}, nil
+		methods := []string{http.MethodOptions, http.MethodPut, "MKCOL"}
+		if b.LockSystem != nil {
+			methods = append(methods, "LOCK")
+		}
+		return caps, methods, nil
 	} else if err != nil {
 		return nil, nil, err
 	}
@@ -78,6 +96,11 @@
 		allow = append(allow, http.MethodHead, http.MethodGet, http.MethodPut)
 	}
 
+	// Add lock methods if lock system is available
+	if b.LockSystem != nil {
+		allow = append(allow, "LOCK", "UNLOCK")
+	}
+
 	return caps, allow, nil
 }
 
@@ -171,6 +194,12 @@
 		}},
 	})
 
+	// Add empty lockdiscovery property when lock system is available
+	// Actual lock information would be added by the lock system if needed
+	if b.LockSystem != nil {
+		props[internal.LockDiscoveryName] = internal.PropFindValue(&internal.LockDiscovery{})
+	}
+
 	if !fi.IsDir {
 		props[internal.GetContentLengthName] = internal.PropFindValue(&internal.GetContentLength{
 			Length: fi.Size,
@@ -281,11 +310,17 @@
 }
 
 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")
+	if b.LockSystem == nil {
+		return nil, false, internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: lock system not available")
+	}
+	return b.LockSystem.Lock(r, depth, timeout, refreshToken)
 }
 
 func (b *backend) Unlock(r *http.Request, tokenHref string) error {
-	return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: unsupported method")
+	if b.LockSystem == nil {
+		return internal.HTTPErrorf(http.StatusMethodNotAllowed, "webdav: lock system not available")
+	}
+	return b.LockSystem.Unlock(r, tokenHref)
 }
 
 // BackendSuppliedHomeSet represents either a CalDAV calendar-home-set or a

Tryanks avatar May 16 '25 06:05 Tryanks