Skip to content

Conversation

@emersion
Copy link
Owner

@emersion emersion commented Mar 12, 2025

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

@Tryanks
Copy link

Tryanks commented May 16, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants