Fix WebDAV/CalDAV/CardDAV bugs, drop username from URLs, harden concurrency
- Root handler now only serves the welcome page for GET/HEAD; all other methods (e.g. OPTIONS, PROPFIND) return 405 with an Allow header instead of always returning 200, fixing client capability probes and PROPFIND misbehavior. - Mount /files/ properly and cache one xwebdav.Handler per authenticated user so its LockSystem persists across requests instead of being recreated per-request (which broke LOCK/UNLOCK). - Remove the username segment from all DAV URLs (/cal/, /card/, /files/ are now identical for every account; the acting user is always resolved via Basic Auth, never the path). - Reintroduce a fixed literal "home" path segment (/cal/home/, /card/home/) to preserve the URL segment depth that go-webdav's caldav/carddav server relies on to classify resources (principal vs. home-set vs. collection vs. object). Removing the username had collapsed this depth, silently misclassifying requests and returning empty <multistatus> responses (DAVx5 "no resources found"). - Replace the store's single global mutex with per-user sharded locks so different users' requests no longer serialize against each other. - Add auth.NewContext test helper, WebDAV handler tests (per-user isolation, lock persistence across requests), and a concurrent multi-user store test. - Update README and copilot-instructions to document the new URL scheme and the go-webdav path-depth classification quirk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+47
-20
@@ -17,9 +17,15 @@ var ErrConflict = errors.New("conflict")
|
||||
|
||||
// Store is a filesystem-backed key/value store for DAV objects.
|
||||
// Each "collection" maps to a directory; each "object" maps to a file.
|
||||
//
|
||||
// Locking is sharded per-user (rather than one global mutex) so that
|
||||
// concurrent requests from different users don't serialize against each
|
||||
// other; operations within a single user's data still block one another.
|
||||
type Store struct {
|
||||
rootDir string
|
||||
mu sync.RWMutex
|
||||
|
||||
locksMu sync.Mutex
|
||||
locks map[string]*sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStore creates or opens a Store rooted at rootDir.
|
||||
@@ -27,7 +33,19 @@ func NewStore(rootDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating store root %q: %w", rootDir, err)
|
||||
}
|
||||
return &Store{rootDir: rootDir}, nil
|
||||
return &Store{rootDir: rootDir, locks: make(map[string]*sync.RWMutex)}, nil
|
||||
}
|
||||
|
||||
// lockFor returns the per-user lock, creating it on first use.
|
||||
func (s *Store) lockFor(user string) *sync.RWMutex {
|
||||
s.locksMu.Lock()
|
||||
defer s.locksMu.Unlock()
|
||||
l, ok := s.locks[user]
|
||||
if !ok {
|
||||
l = &sync.RWMutex{}
|
||||
s.locks[user] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// collectionPath returns the filesystem path for a collection.
|
||||
@@ -42,16 +60,18 @@ func (s *Store) objectPath(user, collection, objectID string) string {
|
||||
|
||||
// EnsureCollection creates the collection directory if it does not exist.
|
||||
func (s *Store) EnsureCollection(user, collection string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.lockFor(user)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
dir := s.collectionPath(user, collection)
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
// ListCollections returns all collection names for a user.
|
||||
func (s *Store) ListCollections(user string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
defer l.RUnlock()
|
||||
|
||||
userDir := filepath.Join(s.rootDir, sanitize(user))
|
||||
entries, err := os.ReadDir(userDir)
|
||||
@@ -73,8 +93,9 @@ func (s *Store) ListCollections(user string) ([]string, error) {
|
||||
|
||||
// GetCollection returns metadata about a collection.
|
||||
func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
defer l.RUnlock()
|
||||
info, err := os.Stat(s.collectionPath(user, collection))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
@@ -84,8 +105,9 @@ func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
|
||||
|
||||
// PutObject writes data to an object, creating or replacing it.
|
||||
func (s *Store) PutObject(user, collection, objectID string, data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.lockFor(user)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
dir := s.collectionPath(user, collection)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -103,8 +125,9 @@ func (s *Store) PutObject(user, collection, objectID string, data []byte) error
|
||||
|
||||
// GetObject reads an object's raw bytes.
|
||||
func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
defer l.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -115,8 +138,9 @@ func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) {
|
||||
|
||||
// DeleteObject removes an object.
|
||||
func (s *Store) DeleteObject(user, collection, objectID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.lockFor(user)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
err := os.Remove(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -127,8 +151,9 @@ func (s *Store) DeleteObject(user, collection, objectID string) error {
|
||||
|
||||
// ListObjects returns all object filenames in a collection.
|
||||
func (s *Store) ListObjects(user, collection string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
defer l.RUnlock()
|
||||
|
||||
dir := s.collectionPath(user, collection)
|
||||
entries, err := os.ReadDir(dir)
|
||||
@@ -150,8 +175,9 @@ func (s *Store) ListObjects(user, collection string) ([]string, error) {
|
||||
|
||||
// StatObject returns FileInfo for an object.
|
||||
func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
defer l.RUnlock()
|
||||
|
||||
info, err := os.Stat(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -162,8 +188,9 @@ func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, erro
|
||||
|
||||
// DeleteCollection removes an entire collection directory.
|
||||
func (s *Store) DeleteCollection(user, collection string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.lockFor(user)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
err := os.RemoveAll(s.collectionPath(user, collection))
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user