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:
2026-08-18 12:49:57 +02:00
co-authored by Copilot
parent 7a11b5bbbf
commit b4644bc590
11 changed files with 465 additions and 100 deletions
+54
View File
@@ -1,7 +1,9 @@
package store_test
import (
"fmt"
"os"
"sync"
"testing"
"github.com/yourusername/caldav-server/internal/store"
@@ -77,3 +79,55 @@ func TestSanitizePath(t *testing.T) {
t.Fatal("path traversal succeeded — security issue!")
}
}
// TestConcurrentMultiUserAccess exercises the store from several users
// concurrently to make sure the per-user locking not only avoids data races
// (checked by -race) but also doesn't serialize unrelated users' operations
// incorrectly (e.g. deadlocks or cross-user data corruption).
func TestConcurrentMultiUserAccess(t *testing.T) {
dir := t.TempDir()
st, err := store.NewStore(dir)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
const users = 8
const objectsPerUser = 20
var wg sync.WaitGroup
for u := 0; u < users; u++ {
user := fmt.Sprintf("user%d", u)
wg.Add(1)
go func(user string) {
defer wg.Done()
for i := 0; i < objectsPerUser; i++ {
id := fmt.Sprintf("obj-%d.ics", i)
data := []byte(fmt.Sprintf("DATA-%s-%d", user, i))
if err := st.PutObject(user, "cal-personal", id, data); err != nil {
t.Errorf("PutObject(%s, %d): %v", user, i, err)
return
}
got, err := st.GetObject(user, "cal-personal", id)
if err != nil {
t.Errorf("GetObject(%s, %d): %v", user, i, err)
return
}
if string(got) != string(data) {
t.Errorf("cross-user data corruption for %s obj %d: got %q want %q", user, i, got, data)
}
}
}(user)
}
wg.Wait()
for u := 0; u < users; u++ {
user := fmt.Sprintf("user%d", u)
ids, err := st.ListObjects(user, "cal-personal")
if err != nil {
t.Fatalf("ListObjects(%s): %v", user, err)
}
if len(ids) != objectsPerUser {
t.Errorf("user %s: expected %d objects, got %d", user, objectsPerUser, len(ids))
}
}
}