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:
@@ -0,0 +1,108 @@
|
||||
# Copilot Instructions for nidus
|
||||
|
||||
A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a
|
||||
filesystem store. HTTP Basic Auth (bcrypt) with per-user isolated collections.
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
```bash
|
||||
make build # go build -o bin/davserver ./cmd/server
|
||||
make run # build + ./bin/davserver -config config.yaml
|
||||
make test # go test ./... -v -race
|
||||
make lint # golangci-lint run ./...
|
||||
make tidy # go mod tidy
|
||||
go test ./internal/store/ -run TestStoreRoundTrip -v # single test
|
||||
```
|
||||
|
||||
There is currently only one test file: `internal/store/store_test.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`,
|
||||
constructs the `store.Store`, pre-creates each user's configured
|
||||
calendars/address-books as collections, wires up `auth.Middleware`, and
|
||||
builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`,
|
||||
`/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated),
|
||||
and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV
|
||||
client pointed at the wrong URL — gets `405` instead of a misleading
|
||||
`200`).
|
||||
- `internal/config` — YAML config loading (`config.Load`), defaults
|
||||
(`applyDefaults`), and validation (`validate`). `Config.Users` is a
|
||||
`map[string]UserConfig` keyed by username; each user has a bcrypt
|
||||
`Password`, `Calendars`, and `AddressBooks` lists that seed collection
|
||||
names.
|
||||
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`).
|
||||
Validates credentials against `cfg.Users` via bcrypt, then stores a
|
||||
`*Principal{Username, DisplayName, Email}` in the request context.
|
||||
Downstream code retrieves it with `auth.FromContext(ctx)` — every backend
|
||||
method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)`
|
||||
if it's nil. `auth.NewContext(ctx, p)` is the test-only inverse, used to
|
||||
build authenticated contexts without a real Basic Auth handshake.
|
||||
- `internal/store` — the single source of truth for all persisted data.
|
||||
A thin filesystem KV abstraction: `<data_dir>/<user>/<collection>/<objectID>`.
|
||||
All collection/object names pass through `sanitize()` (via
|
||||
`filepath.Base` + strip `..`) to prevent path traversal — preserve this
|
||||
when adding new store methods. Writes use temp-file + rename for atomicity
|
||||
(`PutObject`). Locking is sharded per-user (`lockFor(user)`, a
|
||||
`map[string]*sync.RWMutex` guarded by its own mutex) rather than one
|
||||
global lock, so different users' requests don't serialize against each
|
||||
other.
|
||||
- `internal/caldav` and `internal/carddav` — implement the
|
||||
`caldav.Backend`/`carddav.Backend` interfaces from `github.com/emersion/go-webdav`
|
||||
on top of `store.Store`. Calendars are stored as collections prefixed
|
||||
`cal-<name>` and address books as `card-<name>` (see `ListCalendars`,
|
||||
`parseCalPath`). **The URL scheme has no username segment**, but DOES
|
||||
have a fixed literal `home` segment: `/cal/` (principal), `/cal/home/`
|
||||
(calendar-home-set), `/cal/home/<calname>/` (calendar),
|
||||
`/cal/home/<calname>/<objid>` (object) — and equivalently
|
||||
`/card/`, `/card/home/`, `/card/home/<bookname>/`,
|
||||
`/card/home/<bookname>/<objid>` for carddav. These are identical for
|
||||
every user; the acting user always comes from `auth.FromContext(ctx)`,
|
||||
never from the path. **The `home` segment is load-bearing, not
|
||||
cosmetic**: go-webdav's `caldav`/`carddav` server (in the
|
||||
`github.com/emersion/go-webdav` dependency, not our code) classifies
|
||||
each request purely by counting URL path segments relative to the
|
||||
handler's `Prefix` (which we leave `""`) — 1 segment = user principal, 2
|
||||
= home-set, 3 = calendar/address book, 4 = object. If the segment counts
|
||||
don't line up (e.g. removing `home` would make `/cal/` and
|
||||
`/cal/<calname>/` collapse to 1 and 2 segments, misclassifying the
|
||||
calendar collection itself as the home-set), PROPFIND requests silently
|
||||
return an empty `<multistatus>` (200/207, zero `<response>` elements) —
|
||||
no error, just nothing found, which breaks client auto-discovery (e.g.
|
||||
DAVx5 reporting "no resources found"). Keep this in mind when touching
|
||||
`parseCalPath`/`parseObjPath`/`parseBookPath`,
|
||||
`calHomePath`/`cardHomePath`, or `CurrentUserPrincipal` — always
|
||||
preserve the exact segment depth at each level. Query methods
|
||||
(`QueryCalendarObjects`) currently list all objects and filter in-memory
|
||||
via `caldav.Filter` — fine for small collections, not optimized for
|
||||
scale.
|
||||
- `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
|
||||
mounted at the single fixed URL `/files/` for all users (no username in
|
||||
the path either). `NewHandler` caches one `*xwebdav.Handler` per
|
||||
authenticated username (keyed off `auth.FromContext`), each rooted at
|
||||
`<data_dir>/files/<username>/` on disk with its own persistent
|
||||
`LockSystem` — the handler (and its lock table) must be created once and
|
||||
reused, not per-request, or LOCK/UNLOCK state resets on every call.
|
||||
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to
|
||||
generate bcrypt hashes for `config.yaml`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **No username in any DAV URL** (`/cal/`, `/card/`, `/files/` are the same
|
||||
for every account) — the acting user is always resolved from the Basic
|
||||
Auth identity (`auth.FromContext`), never parsed out of the request path.
|
||||
Don't reintroduce a `<user>` path segment when adding routes/paths.
|
||||
- Path parsing in the CalDAV/CardDAV backends assumes fixed URL segment
|
||||
positions (e.g. `cal/home/<calname>/<objid>`) split on `/` — see
|
||||
`parseCalPath`/`parseObjPath`/`parseBookPath`. The `home` segment must
|
||||
stay exactly one fixed literal segment (see note above on go-webdav's
|
||||
segment-count-based resource classification) — don't remove it or add/
|
||||
remove segments elsewhere without re-checking all four resource-type
|
||||
depths still line up. New path-based operations should follow the same
|
||||
segment-index approach for consistency.
|
||||
- Store errors are sentinel values (`store.ErrNotFound`, `store.ErrConflict`)
|
||||
checked with `errors.Is`/direct comparison; backends translate them into
|
||||
`webdav.NewHTTPError` with the appropriate HTTP status.
|
||||
- Logging uses `log/slog` structured fields (e.g. `logger.Warn("...", "user", u, "error", err)`), passed down explicitly to every constructor (`NewBackend`, `NewHandler`, `NewMiddleware`) rather than a global logger.
|
||||
- Config module path is `github.com/yourusername/caldav-server` (go.mod name
|
||||
predates the `nidus` repo rename) — import paths still use this, not `nidus`.
|
||||
@@ -87,7 +87,7 @@ docker run -p 8080:8080 \
|
||||
- **Server**: `http://yourserver:8080`
|
||||
- **Username**: `alice`
|
||||
- **Password**: your plaintext password
|
||||
3. The app will auto-discover calendars at `/cal/alice/`.
|
||||
3. The app will auto-discover calendars at `/cal/`.
|
||||
|
||||
Same flow for **CardDAV** with Contacts app.
|
||||
|
||||
@@ -110,13 +110,23 @@ Use the GNOME Online Accounts panel:
|
||||
|------|-------------|
|
||||
| `/.well-known/caldav` | Redirects to `/cal/` |
|
||||
| `/.well-known/carddav` | Redirects to `/card/` |
|
||||
| `/cal/<user>/` | CalDAV home |
|
||||
| `/cal/<user>/<calendar>/` | Calendar collection |
|
||||
| `/card/<user>/` | CardDAV home |
|
||||
| `/card/<user>/<book>/` | Address book collection |
|
||||
| `/files/<user>/` | WebDAV file storage |
|
||||
| `/cal/` | CalDAV principal (same URL for every user; resolved via Basic Auth) |
|
||||
| `/cal/home/` | Calendar home-set (lists the user's calendars) |
|
||||
| `/cal/home/<calendar>/` | Calendar collection |
|
||||
| `/card/` | CardDAV principal (same URL for every user; resolved via Basic Auth) |
|
||||
| `/card/home/` | Address book home-set (lists the user's address books) |
|
||||
| `/card/home/<book>/` | Address book collection |
|
||||
| `/files/` | WebDAV file storage (same URL for every user; resolved via Basic Auth) |
|
||||
| `/healthz` | Health check (unauthenticated) |
|
||||
|
||||
> **Note:** the `home` segment is a fixed literal (not a username or real
|
||||
> resource) — it exists only to give the calendar/address-book home-set the
|
||||
> path depth that the underlying CalDAV/CardDAV library expects when
|
||||
> classifying resources by URL. Clients should never need to construct
|
||||
> these URLs by hand; they're discovered automatically via
|
||||
> `.well-known` + `current-user-principal` + `calendar-home-set` /
|
||||
> `addressbook-home-set` properties.
|
||||
|
||||
---
|
||||
|
||||
## TLS / Reverse proxy
|
||||
|
||||
+18
-9
@@ -139,16 +139,25 @@ func buildMux(
|
||||
// CalDAV, CardDAV, and file WebDAV — all behind Basic Auth
|
||||
mux.Handle("/cal/", authMw.Wrap(calHandler))
|
||||
mux.Handle("/card/", authMw.Wrap(cardHandler))
|
||||
// mux.Handle("/files/", authMw.Wrap(fileHandler))
|
||||
mux.Handle("/files/", authMw.Wrap(fileHandler))
|
||||
|
||||
// Root — simple HTML welcome page
|
||||
// Root — simple HTML welcome page (unauthenticated)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" && r.Method == "GET" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, welcomePage, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL)
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
authMw.Wrap(fileHandler).ServeHTTP(w, r)
|
||||
// Only plain GET/HEAD get the welcome page. Any other method
|
||||
// (PROPFIND, LOCK, PUT, ...) hitting "/" means a client is pointed
|
||||
// at the wrong URL — reject it explicitly instead of returning a
|
||||
// misleading 200 OK, which breaks WebDAV clients expecting 207.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "not a WebDAV collection; use /cal/, /card/ or /files/", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, welcomePage, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL)
|
||||
})
|
||||
|
||||
return mux
|
||||
@@ -218,9 +227,9 @@ const welcomePage = `<!DOCTYPE html>
|
||||
<p>This server provides CalDAV, CardDAV, and WebDAV access.</p>
|
||||
<h2>Endpoints</h2>
|
||||
<ul>
|
||||
<li><strong>CalDAV</strong> — <code>%s/cal/<username>/</code></li>
|
||||
<li><strong>CardDAV</strong> — <code>%s/card/<username>/</code></li>
|
||||
<li><strong>WebDAV files</strong> — <code>%s/files/<username>/</code></li>
|
||||
<li><strong>CalDAV</strong> — <code>%s/cal/</code></li>
|
||||
<li><strong>CardDAV</strong> — <code>%s/card/</code></li>
|
||||
<li><strong>WebDAV files</strong> — <code>%s/files/</code></li>
|
||||
</ul>
|
||||
<h2>Auto-discovery</h2>
|
||||
<ul>
|
||||
|
||||
@@ -95,6 +95,13 @@ func FromContext(ctx context.Context) *Principal {
|
||||
return p
|
||||
}
|
||||
|
||||
// NewContext returns a copy of ctx carrying p, retrievable via FromContext.
|
||||
// This is primarily useful for tests of downstream packages that need an
|
||||
// authenticated context without going through the Basic Auth handshake.
|
||||
func NewContext(ctx context.Context, p *Principal) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, p)
|
||||
}
|
||||
|
||||
var errUnauthorized = &authError{msg: "invalid credentials"}
|
||||
|
||||
type authError struct{ msg string }
|
||||
|
||||
+31
-16
@@ -38,19 +38,23 @@ func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.H
|
||||
// -------- caldav.Backend interface --------
|
||||
|
||||
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
p := auth.FromContext(ctx)
|
||||
if p == nil {
|
||||
if auth.FromContext(ctx) == nil {
|
||||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||||
}
|
||||
return principalPath(p.Username), nil
|
||||
// Must resolve to a path served by this same handler (i.e. under /cal/)
|
||||
// at exactly one path segment of depth, since go-webdav's caldav server
|
||||
// classifies resources purely by path depth relative to Handler.Prefix:
|
||||
// depth 1 = principal, depth 2 = home-set, depth 3 = calendar, depth 4 =
|
||||
// calendar object. A /principals/<user>/ path would never be reached
|
||||
// (nothing is mounted there) and would break discovery.
|
||||
return calPrincipalPath(), nil
|
||||
}
|
||||
|
||||
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||
p := auth.FromContext(ctx)
|
||||
if p == nil {
|
||||
if auth.FromContext(ctx) == nil {
|
||||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||||
}
|
||||
return calHomePath(p.Username), nil
|
||||
return calHomePath(), nil
|
||||
}
|
||||
|
||||
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||
@@ -132,7 +136,7 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
obj, err := b.decodeObject(calObjectPath(user, calName, id), data)
|
||||
obj, err := b.decodeObject(calObjectPath(calName, id), data)
|
||||
if err != nil {
|
||||
b.logger.Warn("decoding calendar object", "id", id, "error", err)
|
||||
continue
|
||||
@@ -204,7 +208,7 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
|
||||
|
||||
func (b *Backend) calendarMeta(user, name string) caldav.Calendar {
|
||||
return caldav.Calendar{
|
||||
Path: calHomePath(user) + name + "/",
|
||||
Path: calHomePath() + name + "/",
|
||||
Name: name,
|
||||
Description: fmt.Sprintf("%s's %s calendar", user, name),
|
||||
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
|
||||
@@ -236,7 +240,7 @@ func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calNa
|
||||
}
|
||||
user = p.Username
|
||||
parts := strings.Split(strings.Trim(calPath, "/"), "/")
|
||||
// expected: cal/<user>/<calname>/
|
||||
// expected: cal/home/<calname>/
|
||||
if len(parts) < 3 {
|
||||
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path"))
|
||||
}
|
||||
@@ -251,7 +255,7 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calNa
|
||||
}
|
||||
user = p.Username
|
||||
parts := strings.Split(strings.Trim(objPath, "/"), "/")
|
||||
// expected: cal/<user>/<calname>/<objid>
|
||||
// expected: cal/home/<calname>/<objid>
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
|
||||
}
|
||||
@@ -260,16 +264,27 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calNa
|
||||
return user, calName, objID, nil
|
||||
}
|
||||
|
||||
func principalPath(user string) string {
|
||||
return fmt.Sprintf("/principals/%s/", user)
|
||||
// calPrincipalPath, calHomePath, and calObjectPath are the same for every
|
||||
// user: authorization is resolved from the Basic Auth identity, not from
|
||||
// the URL, so no username segment is needed in the path.
|
||||
//
|
||||
// The fixed "home" segment (in place of a username) is required, not
|
||||
// cosmetic: go-webdav's caldav server classifies a request purely by how
|
||||
// many path segments it has relative to the handler's mount point — 1
|
||||
// segment is treated as the principal, 2 as the calendar-home-set, 3 as a
|
||||
// calendar, 4 as a calendar object. Removing that segment entirely would
|
||||
// make the home-set and calendar paths misclassified as principal/home-set
|
||||
// respectively, breaking discovery (empty PROPFIND responses).
|
||||
func calPrincipalPath() string {
|
||||
return "/cal/"
|
||||
}
|
||||
|
||||
func calHomePath(user string) string {
|
||||
return fmt.Sprintf("/cal/%s/", user)
|
||||
func calHomePath() string {
|
||||
return "/cal/home/"
|
||||
}
|
||||
|
||||
func calObjectPath(user, calName, objID string) string {
|
||||
return fmt.Sprintf("/cal/%s/%s/%s", user, calName, objID)
|
||||
func calObjectPath(calName, objID string) string {
|
||||
return fmt.Sprintf("/cal/home/%s/%s", calName, objID)
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) uint64 {
|
||||
|
||||
+28
-16
@@ -38,19 +38,19 @@ func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.H
|
||||
// -------- carddav.Backend interface --------
|
||||
|
||||
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
p := auth.FromContext(ctx)
|
||||
if p == nil {
|
||||
if auth.FromContext(ctx) == nil {
|
||||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||||
}
|
||||
return principalPath(p.Username), nil
|
||||
// Must resolve to a path served by this same handler (i.e. under
|
||||
// /card/) at exactly one path segment of depth — see cardPrincipalPath.
|
||||
return cardPrincipalPath(), nil
|
||||
}
|
||||
|
||||
func (b *Backend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
|
||||
p := auth.FromContext(ctx)
|
||||
if p == nil {
|
||||
if auth.FromContext(ctx) == nil {
|
||||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||||
}
|
||||
return cardHomePath(p.Username), nil
|
||||
return cardHomePath(), nil
|
||||
}
|
||||
|
||||
func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
|
||||
@@ -132,7 +132,7 @@ func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req *
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
obj, err := b.decodeObject(cardObjectPath(user, bookName, id), data)
|
||||
obj, err := b.decodeObject(cardObjectPath(bookName, id), data)
|
||||
if err != nil {
|
||||
b.logger.Warn("decoding vcard object", "id", id, "error", err)
|
||||
continue
|
||||
@@ -202,7 +202,7 @@ func (b *Backend) DeleteAddressObject(ctx context.Context, objPath string) error
|
||||
|
||||
func (b *Backend) bookMeta(user, name string) carddav.AddressBook {
|
||||
return carddav.AddressBook{
|
||||
Path: cardHomePath(user) + name + "/",
|
||||
Path: cardHomePath() + name + "/",
|
||||
Name: name,
|
||||
Description: fmt.Sprintf("%s's %s address book", user, name),
|
||||
MaxResourceSize: 10 * 1024 * 1024,
|
||||
@@ -234,7 +234,7 @@ func (b *Backend) parseBookPath(ctx context.Context, bookPath string) (user, boo
|
||||
}
|
||||
user = p.Username
|
||||
parts := strings.Split(strings.Trim(bookPath, "/"), "/")
|
||||
// expected: card/<user>/<bookname>/
|
||||
// expected: card/home/<bookname>/
|
||||
if len(parts) < 3 {
|
||||
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid address book path"))
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, bookN
|
||||
}
|
||||
user = p.Username
|
||||
parts := strings.Split(strings.Trim(objPath, "/"), "/")
|
||||
// expected: card/<user>/<bookname>/<objid>
|
||||
// expected: card/home/<bookname>/<objid>
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
|
||||
}
|
||||
@@ -258,16 +258,28 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, bookN
|
||||
return user, bookName, objID, nil
|
||||
}
|
||||
|
||||
func principalPath(user string) string {
|
||||
return fmt.Sprintf("/principals/%s/", user)
|
||||
// cardPrincipalPath, cardHomePath, and cardObjectPath are the same for
|
||||
// every user: authorization is resolved from the Basic Auth identity, not
|
||||
// from the URL, so no username segment is needed in the path.
|
||||
//
|
||||
// The fixed "home" segment (in place of a username) is required, not
|
||||
// cosmetic: go-webdav's carddav server classifies a request purely by how
|
||||
// many path segments it has relative to the handler's mount point — 1
|
||||
// segment is treated as the principal, 2 as the addressbook-home-set, 3 as
|
||||
// an address book, 4 as an address object. Removing that segment entirely
|
||||
// would make the home-set and address-book paths misclassified as
|
||||
// principal/home-set respectively, breaking discovery (empty PROPFIND
|
||||
// responses).
|
||||
func cardPrincipalPath() string {
|
||||
return "/card/"
|
||||
}
|
||||
|
||||
func cardHomePath(user string) string {
|
||||
return fmt.Sprintf("/card/%s/", user)
|
||||
func cardHomePath() string {
|
||||
return "/card/home/"
|
||||
}
|
||||
|
||||
func cardObjectPath(user, bookName, objID string) string {
|
||||
return fmt.Sprintf("/card/%s/%s/%s", user, bookName, objID)
|
||||
func cardObjectPath(bookName, objID string) string {
|
||||
return fmt.Sprintf("/card/home/%s/%s", bookName, objID)
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) uint64 {
|
||||
|
||||
+10
-10
@@ -9,12 +9,12 @@ import (
|
||||
|
||||
// Config is the top-level server configuration.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Users map[string]UserConfig `yaml:"users"`
|
||||
TLS TLSConfig `yaml:"tls"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Users map[string]UserConfig `yaml:"users"`
|
||||
TLS TLSConfig `yaml:"tls"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -36,10 +36,10 @@ type StorageConfig struct {
|
||||
|
||||
type UserConfig struct {
|
||||
// bcrypt-hashed password (use `htpasswd -nB <user>`)
|
||||
Password string `yaml:"password"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Email string `yaml:"email"`
|
||||
Calendars []string `yaml:"calendars"`
|
||||
Password string `yaml:"password"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Email string `yaml:"email"`
|
||||
Calendars []string `yaml:"calendars"`
|
||||
AddressBooks []string `yaml:"address_books"`
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-23
@@ -5,15 +5,28 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/yourusername/caldav-server/internal/auth"
|
||||
"github.com/yourusername/caldav-server/internal/config"
|
||||
xwebdav "golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
// NewHandler returns an http.Handler that provides standard WebDAV file access
|
||||
// per-user under dataDir/files/<username>/.
|
||||
// NewHandler returns an http.Handler that provides standard WebDAV file access,
|
||||
// mounted at the fixed URL /files/ for every user and rooted at
|
||||
// dataDir/files/<username>/ on disk. The URL is the same for all users —
|
||||
// which user's directory is served is resolved from the Basic Auth identity
|
||||
// in the request context, not from the URL.
|
||||
//
|
||||
// A dedicated xwebdav.Handler (with its own persistent LockSystem) is created
|
||||
// once per user and cached, so LOCK/UNLOCK state survives across requests
|
||||
// instead of being reset on every call.
|
||||
func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Handler {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
handlers = make(map[string]http.Handler)
|
||||
)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := auth.FromContext(r.Context())
|
||||
if p == nil {
|
||||
@@ -21,29 +34,39 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
|
||||
return
|
||||
}
|
||||
|
||||
userDir := filepath.Join(dataDir, "files", p.Username)
|
||||
logger.Debug(userDir)
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
logger.Error("creating user WebDAV dir", "user", p.Username, "error", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
h, ok := handlers[p.Username]
|
||||
if !ok {
|
||||
username := p.Username
|
||||
userDir := filepath.Join(dataDir, "files", username)
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
mu.Unlock()
|
||||
logger.Error("creating user WebDAV dir", "user", username, "error", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Each user gets their own isolated WebDAV handler so paths don't bleed.
|
||||
h := &xwebdav.Handler{
|
||||
FileSystem: xwebdav.Dir(userDir),
|
||||
LockSystem: xwebdav.NewMemLS(),
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
logger.Warn("WebDAV error",
|
||||
"user", p.Username,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"error", err)
|
||||
}
|
||||
},
|
||||
Prefix: "/", //fmt.Sprintf("/files/%s", p.Username),
|
||||
// Each user gets their own isolated WebDAV handler (and lock
|
||||
// system) so paths and locks don't bleed between users, even
|
||||
// though they all share the same "/files/" URL.
|
||||
h = &xwebdav.Handler{
|
||||
FileSystem: xwebdav.Dir(userDir),
|
||||
LockSystem: xwebdav.NewMemLS(),
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
logger.Warn("WebDAV error",
|
||||
"user", username,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"error", err)
|
||||
}
|
||||
},
|
||||
Prefix: "/files",
|
||||
}
|
||||
handlers[username] = h
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package filewebdav_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yourusername/caldav-server/internal/auth"
|
||||
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func doAs(t *testing.T, h http.Handler, user, method, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
ctx := auth.NewContext(req.Context(), &auth.Principal{Username: user})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestPerUserIsolationAndPrefix verifies that all users share the same
|
||||
// "/files/" URL, but each is served from (and can only see) their own
|
||||
// directory on disk, resolved from the Basic Auth identity.
|
||||
func TestPerUserIsolationAndPrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h := filewebdav.NewHandler(nil, dir, testLogger())
|
||||
|
||||
putRec := doAs(t, h, "alice", http.MethodPut, "/files/note.txt", "hello alice")
|
||||
if putRec.Code != http.StatusCreated && putRec.Code != http.StatusNoContent {
|
||||
t.Fatalf("PUT as alice: unexpected status %d: %s", putRec.Code, putRec.Body.String())
|
||||
}
|
||||
|
||||
getRec := doAs(t, h, "alice", http.MethodGet, "/files/note.txt", "")
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET as alice: unexpected status %d", getRec.Code)
|
||||
}
|
||||
if getRec.Body.String() != "hello alice" {
|
||||
t.Fatalf("unexpected body: %q", getRec.Body.String())
|
||||
}
|
||||
|
||||
// bob hits the exact same URL, but must not see alice's file — his own
|
||||
// directory on disk is empty.
|
||||
bobRec := doAs(t, h, "bob", http.MethodGet, "/files/note.txt", "")
|
||||
if bobRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
|
||||
}
|
||||
|
||||
// Confirm the file physically landed under dataDir/files/alice/, not
|
||||
// nested under an extra files/files/... path.
|
||||
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil {
|
||||
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockPersistsAcrossRequests ensures the LockSystem used by the handler
|
||||
// is not recreated (and thus reset) on every request.
|
||||
func TestLockPersistsAcrossRequests(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h := filewebdav.NewHandler(nil, dir, testLogger())
|
||||
|
||||
// Create the file first.
|
||||
doAs(t, h, "alice", http.MethodPut, "/files/locked.txt", "v1")
|
||||
|
||||
lockBody := `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:lockinfo xmlns:D="DAV:">
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner><D:href>test</D:href></D:owner>
|
||||
</D:lockinfo>`
|
||||
|
||||
lockRec := doAs(t, h, "alice", "LOCK", "/files/locked.txt", lockBody)
|
||||
if lockRec.Code != http.StatusOK {
|
||||
t.Fatalf("LOCK: unexpected status %d: %s", lockRec.Code, lockRec.Body.String())
|
||||
}
|
||||
locktoken := lockRec.Header().Get("Lock-Token")
|
||||
if locktoken == "" {
|
||||
t.Fatal("expected Lock-Token header in LOCK response")
|
||||
}
|
||||
|
||||
// A second, unrelated request must still see the lock as active,
|
||||
// proving the LockSystem instance was reused rather than reset.
|
||||
req := httptest.NewRequest(http.MethodPut, "/files/locked.txt", strings.NewReader("v2 without token"))
|
||||
ctx := auth.NewContext(req.Context(), &auth.Principal{Username: "alice"})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusLocked {
|
||||
t.Fatalf("expected 423 Locked for PUT without lock token, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user