Files
nidus/internal/caldav/backend.go
T
arnefandCopilot b4644bc590 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>
2026-08-18 12:49:57 +02:00

298 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package caldav
import (
"context"
"fmt"
"log/slog"
"net/http"
"path"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/store"
)
// Backend implements caldav.Backend using a filesystem store.
type Backend struct {
cfg *config.Config
store *store.Store
logger *slog.Logger
}
// NewBackend creates a CalDAV backend.
func NewBackend(cfg *config.Config, st *store.Store, logger *slog.Logger) *Backend {
return &Backend{cfg: cfg, store: st, logger: logger}
}
// NewHandler returns an http.Handler for the /cal/ prefix.
func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.Handler {
b := NewBackend(cfg, st, logger)
return &caldav.Handler{Backend: b}
}
// -------- caldav.Backend interface --------
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
// 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) {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return calHomePath(), nil
}
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
p := auth.FromContext(ctx)
if p == nil {
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user, ok := b.cfg.Users[p.Username]
if !ok {
return nil, fmt.Errorf("user not found")
}
var cals []caldav.Calendar
for _, name := range user.Calendars {
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
continue
}
cals = append(cals, b.calendarMeta(p.Username, name))
}
// Also include any extra calendars that exist on disk but aren't in config
disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool)
for _, n := range user.Calendars {
configured["cal-"+n] = true
}
for _, dir := range disk {
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
name := strings.TrimPrefix(dir, "cal-")
cals = append(cals, b.calendarMeta(p.Username, name))
}
}
return cals, nil
}
func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) {
user, name, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
if _, err := b.store.GetCollection(user, "cal-"+name); err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal := b.calendarMeta(user, name)
return &cal, nil
}
func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
data, err := b.store.GetObject(user, "cal-"+calName, objID)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
return b.decodeObject(objPath, data)
}
func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
user, calName, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
ids, err := b.store.ListObjects(user, "cal-"+calName)
if err != nil {
return nil, err
}
var objs []caldav.CalendarObject
for _, id := range ids {
data, err := b.store.GetObject(user, "cal-"+calName, id)
if err != nil {
continue
}
obj, err := b.decodeObject(calObjectPath(calName, id), data)
if err != nil {
b.logger.Warn("decoding calendar object", "id", id, "error", err)
continue
}
objs = append(objs, *obj)
}
return objs, nil
}
func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
// List all and filter sufficient for small collections.
all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest)
if err != nil {
return nil, err
}
return caldav.Filter(query, all)
}
func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
p := auth.FromContext(ctx)
if p == nil {
return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
// Derive collection name from the trailing path segment.
name := path.Base(strings.TrimSuffix(calendar.Path, "/"))
return b.store.EnsureCollection(p.Username, "cal-"+name)
}
func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
user, name, err := b.parseCalPath(ctx, calPath)
if err != nil {
return err
}
return b.store.DeleteCollection(user, "cal-"+name)
}
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
var buf strings.Builder
enc := ical.NewEncoder(&buf)
if err := enc.Encode(calendar); err != nil {
return nil, fmt.Errorf("encoding calendar: %w", err)
}
data := []byte(buf.String())
if err := b.store.PutObject(user, "cal-"+calName, objID, data); err != nil {
return nil, fmt.Errorf("storing calendar object: %w", err)
}
return b.decodeObject(objPath, data)
}
func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) error {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return err
}
if err := b.store.DeleteObject(user, "cal-"+calName, objID); err != nil {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
return nil
}
// -------- helpers --------
func (b *Backend) calendarMeta(user, name string) caldav.Calendar {
return caldav.Calendar{
Path: calHomePath() + name + "/",
Name: name,
Description: fmt.Sprintf("%s's %s calendar", user, name),
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
MaxResourceSize: 10 * 1024 * 1024, // 10 MiB
}
}
func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) {
cal, err := ical.NewDecoder(strings.NewReader(string(data))).Decode()
if err != nil {
return nil, fmt.Errorf("decoding ical: %w", err)
}
etag := fmt.Sprintf(`"%x"`, hashBytes(data))
return &caldav.CalendarObject{
Path: objPath,
ModTime: time.Now(),
ContentLength: int64(len(data)),
ETag: etag,
Data: cal,
}, nil
}
func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calName string, err error) {
p := auth.FromContext(ctx)
if p == nil {
return "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user = p.Username
parts := strings.Split(strings.Trim(calPath, "/"), "/")
// expected: cal/home/<calname>/
if len(parts) < 3 {
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path"))
}
calName = parts[2]
return user, calName, nil
}
func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calName, objID string, err error) {
p := auth.FromContext(ctx)
if p == nil {
return "", "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user = p.Username
parts := strings.Split(strings.Trim(objPath, "/"), "/")
// expected: cal/home/<calname>/<objid>
if len(parts) < 4 {
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
}
calName = parts[2]
objID = path.Base(objPath)
return user, calName, objID, nil
}
// 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() string {
return "/cal/home/"
}
func calObjectPath(calName, objID string) string {
return fmt.Sprintf("/cal/home/%s/%s", calName, objID)
}
func hashBytes(data []byte) uint64 {
var h uint64 = 14695981039346656037
for _, b := range data {
h ^= uint64(b)
h *= 1099511628211
}
return h
}