From 7a11b5bbbfe4ad934afcd10997805a7681ed925b Mon Sep 17 00:00:00 2001 From: arnef Date: Thu, 23 Apr 2026 21:56:59 +0200 Subject: [PATCH] wip --- .gitignore | 1 + Dockerfile | 25 ++++ Makefile | 34 +++++ README.md | 225 ++++++++++++++++++++++++++++ cmd/server/main.go | 232 ++++++++++++++++++++++++++++ config.yaml | 34 +++++ docker-compose.yaml | 17 +++ go.mod | 14 ++ go.sum | 16 ++ internal/auth/middleware.go | 102 +++++++++++++ internal/caldav/backend.go | 282 +++++++++++++++++++++++++++++++++++ internal/carddav/backend.go | 280 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 112 ++++++++++++++ internal/store/store.go | 179 ++++++++++++++++++++++ internal/store/store_test.go | 79 ++++++++++ internal/webdav/handler.go | 49 ++++++ tools/hashpwd/main.go | 29 ++++ 17 files changed, 1710 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 config.yaml create mode 100644 docker-compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/middleware.go create mode 100644 internal/caldav/backend.go create mode 100644 internal/carddav/backend.go create mode 100644 internal/config/config.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/webdav/handler.go create mode 100644 tools/hashpwd/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4785ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# ---- Build stage ---- +FROM golang:1.22-alpine AS builder + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/davserver ./cmd/server + +# ---- Runtime stage ---- +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app + +COPY --from=builder /bin/davserver /usr/local/bin/davserver + +# Default data and config locations +VOLUME ["/app/data"] +COPY config.yaml /app/config.yaml + +EXPOSE 8080 + +ENTRYPOINT ["davserver", "-config", "/app/config.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5108638 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test tidy lint docker hash-password + +## build: compile the binary +build: + go build -o bin/davserver ./cmd/server + +## run: run the server locally +run: build + ./bin/davserver -config config.yaml + +## test: run all tests +test: + go test ./... -v -race + +## tidy: tidy go modules +tidy: + go mod tidy + +## lint: run golangci-lint +lint: + golangci-lint run ./... + +## docker: build Docker image +docker: + docker build -t davserver:latest . + +## compose: start with Docker Compose +compose: + docker compose up --build + +## hash-password: generate bcrypt hash for a password +## Usage: make hash-password PWD=mysecret +hash-password: + @go run -v ./tools/hashpwd $(PWD) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2ce9aa --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +# DAV Server + +A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. + +## Features + +| Protocol | Use case | +|----------|----------| +| CalDAV | Calendars — sync with Apple Calendar, Thunderbird, GNOME Calendar, … | +| CardDAV | Contacts — sync with Apple Contacts, GNOME Contacts, … | +| WebDAV | General file access via Finder, Windows Explorer, Nautilus, … | + +- HTTP Basic Auth with **bcrypt** password storage +- Per-user isolated collections +- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav` +- Optional **TLS** (or use a reverse proxy) +- Structured logging (text or JSON) +- Graceful shutdown +- Docker & Docker Compose support + +## Quick start + +### 1. Install dependencies + +```bash +go mod tidy +``` + +### 2. Generate password hashes + +```bash +go run ./tools/hashpwd mysecretpassword +# Outputs: $2b$12$... +``` + +### 3. Edit `config.yaml` + +Replace the placeholder hashes with your real bcrypt hashes: + +```yaml +users: + alice: + password: "$2b$12$" + display_name: "Alice Smith" + email: "alice@example.com" + calendars: + - personal + - work + address_books: + - contacts +``` + +### 4. Run the server + +```bash +make run +# or +go run ./cmd/server -config config.yaml +``` + +The server starts at **http://localhost:8080**. + +--- + +## Docker + +```bash +# Build and start +docker compose up --build + +# Or build manually +docker build -t davserver . +docker run -p 8080:8080 \ + -v ./config.yaml:/app/config.yaml:ro \ + -v dav-data:/app/data \ + davserver +``` + +--- + +## Client configuration + +### Apple Calendar / Contacts (macOS / iOS) + +1. Go to **Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account** +2. Enter: + - **Server**: `http://yourserver:8080` + - **Username**: `alice` + - **Password**: your plaintext password +3. The app will auto-discover calendars at `/cal/alice/`. + +Same flow for **CardDAV** with Contacts app. + +### Thunderbird + +1. Install the **TbSync** add-on + **CalDAV & CardDAV** provider +2. Add a new account and point it at `http://yourserver:8080/.well-known/caldav` + +### GNOME Calendar / Evolution + +Use the GNOME Online Accounts panel: +- **Server**: `http://yourserver:8080` +- Check *CalDAV* / *CardDAV* as appropriate + +--- + +## API endpoints + +| Path | Description | +|------|-------------| +| `/.well-known/caldav` | Redirects to `/cal/` | +| `/.well-known/carddav` | Redirects to `/card/` | +| `/cal//` | CalDAV home | +| `/cal///` | Calendar collection | +| `/card//` | CardDAV home | +| `/card///` | Address book collection | +| `/files//` | WebDAV file storage | +| `/healthz` | Health check (unauthenticated) | + +--- + +## TLS / Reverse proxy + +### Self-signed certificate (development) + +```bash +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes +``` + +Update `config.yaml`: + +```yaml +tls: + enabled: true + cert_file: cert.pem + key_file: key.pem +``` + +### Caddy reverse proxy (recommended for production) + +``` +dav.example.com { + reverse_proxy localhost:8080 +} +``` + +--- + +## Configuration reference + +```yaml +server: + host: "0.0.0.0" + port: 8080 + base_url: "https://dav.example.com" # used in DAV responses + +auth: + realm: "My DAV Server" + +storage: + data_dir: "./data" # all user data lives here + +logging: + level: "info" # debug | info | warn | error + format: "text" # text | json + +tls: + enabled: false + cert_file: "" + key_file: "" + +users: + : + password: "" + display_name: "Full Name" + email: "user@example.com" + calendars: # pre-created calendar names + - personal + address_books: # pre-created address book names + - contacts +``` + +--- + +## Project layout + +``` +caldav-server/ +├── cmd/server/ # main entrypoint +├── internal/ +│ ├── auth/ # HTTP Basic Auth middleware +│ ├── caldav/ # CalDAV backend +│ ├── carddav/ # CardDAV backend +│ ├── config/ # YAML config loader +│ ├── store/ # filesystem storage layer +│ └── webdav/ # WebDAV file handler +├── tools/hashpwd/ # bcrypt password hasher CLI +├── config.yaml # sample configuration +├── Dockerfile +├── docker-compose.yaml +└── Makefile +``` + +--- + +## Running tests + +```bash +make test +# or +go test ./... -race +``` + +--- + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `github.com/emersion/go-webdav` | WebDAV/CalDAV/CardDAV protocol layer | +| `github.com/emersion/go-ical` | iCalendar parsing/serialisation | +| `github.com/emersion/go-vcard` | vCard parsing/serialisation | +| `golang.org/x/crypto` | bcrypt | +| `golang.org/x/net` | `golang.org/x/net/webdav` | +| `gopkg.in/yaml.v3` | YAML config parsing | diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..2156059 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/yourusername/caldav-server/internal/auth" + "github.com/yourusername/caldav-server/internal/caldav" + "github.com/yourusername/caldav-server/internal/carddav" + "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/store" + filewebdav "github.com/yourusername/caldav-server/internal/webdav" +) + +func main() { + var cfgPath string + flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file") + flag.Parse() + + // ---- Configuration ---- + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) + os.Exit(1) + } + + // ---- Logging ---- + logger := buildLogger(cfg) + logger.Info("starting DAV server", + "host", cfg.Server.Host, + "port", cfg.Server.Port, + "base_url", cfg.Server.BaseURL, + "tls", cfg.TLS.Enabled) + + // ---- Storage ---- + st, err := store.NewStore(cfg.Storage.DataDir) + if err != nil { + logger.Error("initialising store", "error", err) + os.Exit(1) + } + + // Pre-create default collections for each user + for username, user := range cfg.Users { + for _, cal := range user.Calendars { + if err := st.EnsureCollection(username, "cal-"+cal); err != nil { + logger.Warn("creating calendar collection", "user", username, "cal", cal, "error", err) + } + } + for _, book := range user.AddressBooks { + if err := st.EnsureCollection(username, "card-"+book); err != nil { + logger.Warn("creating address book collection", "user", username, "book", book, "error", err) + } + } + } + + // ---- Middleware ---- + authMw := auth.NewMiddleware(cfg, logger) + + // ---- Handlers ---- + calHandler := caldav.NewHandler(cfg, st, logger) + cardHandler := carddav.NewHandler(cfg, st, logger) + fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger) + + mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, logger) + + // ---- HTTP Server ---- + addr := net.JoinHostPort(cfg.Server.Host, fmt.Sprintf("%d", cfg.Server.Port)) + srv := &http.Server{ + Addr: addr, + Handler: withRequestLogging(mux, logger), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // ---- Graceful shutdown ---- + idleConnsClosed := make(chan struct{}) + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.Error("graceful shutdown failed", "error", err) + } + close(idleConnsClosed) + }() + + logger.Info("server ready", "addr", addr) + if cfg.TLS.Enabled { + err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile) + } else { + err = srv.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + logger.Error("server error", "error", err) + os.Exit(1) + } + + <-idleConnsClosed + logger.Info("server stopped") +} + +// buildMux creates the top-level HTTP multiplexer. +func buildMux( + cfg *config.Config, + authMw *auth.Middleware, + calHandler, cardHandler, fileHandler http.Handler, + logger *slog.Logger, +) *http.ServeMux { + mux := http.NewServeMux() + + // /.well-known/ redirects for auto-discovery + mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cfg.Server.BaseURL+"/cal/", http.StatusMovedPermanently) + }) + mux.HandleFunc("/.well-known/carddav", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cfg.Server.BaseURL+"/card/", http.StatusMovedPermanently) + }) + + // Health check (unauthenticated) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + // 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)) + + // Root — simple HTML welcome page + 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) + return + } + authMw.Wrap(fileHandler).ServeHTTP(w, r) + }) + + return mux +} + +// withRequestLogging wraps a handler with access-log middleware. +func withRequestLogging(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w, code: http.StatusOK} + next.ServeHTTP(rw, r) + logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.code, + "duration_ms", time.Since(start).Milliseconds(), + "remote_addr", r.RemoteAddr, + ) + }) +} + +// responseWriter captures the status code for logging. +type responseWriter struct { + http.ResponseWriter + code int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.code = code + rw.ResponseWriter.WriteHeader(code) +} + +// buildLogger creates a slog.Logger based on config. +func buildLogger(cfg *config.Config) *slog.Logger { + level := slog.LevelInfo + switch cfg.Logging.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + var handler slog.Handler + opts := &slog.HandlerOptions{Level: level} + if cfg.Logging.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + return slog.New(handler) +} + +const welcomePage = ` + +DAV Server + + + +

🗓 DAV Server

+

This server provides CalDAV, CardDAV, and WebDAV access.

+

Endpoints

+
    +
  • CalDAV%s/cal/<username>/
  • +
  • CardDAV%s/card/<username>/
  • +
  • WebDAV files%s/files/<username>/
  • +
+

Auto-discovery

+
    +
  • %s/.well-known/caldav
  • +
  • %s/.well-known/carddav
  • +
+

Authentication: HTTP Basic Auth

+ +` diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..9e4c66f --- /dev/null +++ b/config.yaml @@ -0,0 +1,34 @@ +server: + host: "0.0.0.0" + port: 8080 + # Set this to your public-facing URL so discovery responses are correct. + # base_url: "https://dav.example.com" + +auth: + realm: "My DAV Server" + +storage: + data_dir: "./data" + +logging: + level: "debug" # debug | info | warn | error + format: "text" # text | json + +tls: + enabled: false + # cert_file: "/etc/ssl/certs/dav.crt" + # key_file: "/etc/ssl/private/dav.key" + +# Users: passwords must be bcrypt hashes. +# Generate with: htpasswd -nB alice +# or in Go: bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost) +users: + alice: + password: "$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O" # mysecretpassword + display_name: "Alice Smith" + email: "alice@example.com" + calendars: + - personal + - work + address_books: + - contacts diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a41e1c0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,17 @@ +services: + davserver: + build: . + ports: + - "8080:8080" + volumes: + - ./config.yaml:/app/config.yaml:ro + - dav-data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + dav-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2ec7722 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/yourusername/caldav-server + +go 1.22 + +require ( + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 + github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 + github.com/emersion/go-webdav v0.6.0 + golang.org/x/crypto v0.21.0 + golang.org/x/net v0.22.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/teambition/rrule-go v1.8.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c2e882 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w= +github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..7c8e41d --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "log/slog" + "net/http" + + "github.com/yourusername/caldav-server/internal/config" + "golang.org/x/crypto/bcrypt" +) + +type contextKey string + +const userContextKey contextKey = "authenticated_user" + +// Middleware wraps an http.Handler with HTTP Basic Auth enforcement. +type Middleware struct { + cfg *config.Config + logger *slog.Logger +} + +func NewMiddleware(cfg *config.Config, logger *slog.Logger) *Middleware { + return &Middleware{cfg: cfg, logger: logger} +} + +// Wrap returns an http.Handler that requires valid Basic Auth credentials +// before delegating to next. +func (m *Middleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + m.challenge(w) + return + } + + user, err := m.authenticate(username, password) + if err != nil { + m.logger.Warn("authentication failed", + "username", username, + "remote_addr", r.RemoteAddr, + "error", err) + m.challenge(w) + return + } + + m.logger.Debug("authenticated request", + "username", username, + "method", r.Method, + "path", r.URL.Path) + + ctx := context.WithValue(r.Context(), userContextKey, &Principal{ + Username: username, + DisplayName: user.DisplayName, + Email: user.Email, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// challenge sends a 401 response requesting Basic Auth. +func (m *Middleware) challenge(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="`+m.cfg.Auth.Realm+`"`) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) +} + +// authenticate validates username/password against config. +func (m *Middleware) authenticate(username, password string) (*config.UserConfig, error) { + user, ok := m.cfg.Users[username] + if !ok { + // constant-time comparison to avoid timing attacks + _ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalid"), []byte(password)) + return nil, errUnauthorized + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return nil, errUnauthorized + } + + return &user, nil +} + +// Principal holds the authenticated user's identity. +type Principal struct { + Username string + DisplayName string + Email string +} + +// FromContext extracts the Principal from a request context. +// Returns nil if the request was not authenticated. +func FromContext(ctx context.Context) *Principal { + p, _ := ctx.Value(userContextKey).(*Principal) + return p +} + +var errUnauthorized = &authError{msg: "invalid credentials"} + +type authError struct{ msg string } + +func (e *authError) Error() string { return e.msg } diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go new file mode 100644 index 0000000..e0b8f3d --- /dev/null +++ b/internal/caldav/backend.go @@ -0,0 +1,282 @@ +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) { + p := auth.FromContext(ctx) + if p == nil { + return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + } + return principalPath(p.Username), nil +} + +func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) { + p := auth.FromContext(ctx) + if p == nil { + return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + } + return calHomePath(p.Username), 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(user, 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(user) + 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/// + 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/// + 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 +} + +func principalPath(user string) string { + return fmt.Sprintf("/principals/%s/", user) +} + +func calHomePath(user string) string { + return fmt.Sprintf("/cal/%s/", user) +} + +func calObjectPath(user, calName, objID string) string { + return fmt.Sprintf("/cal/%s/%s/%s", user, calName, objID) +} + +func hashBytes(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h ^= uint64(b) + h *= 1099511628211 + } + return h +} diff --git a/internal/carddav/backend.go b/internal/carddav/backend.go new file mode 100644 index 0000000..cd0f81d --- /dev/null +++ b/internal/carddav/backend.go @@ -0,0 +1,280 @@ +package carddav + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "path" + "strings" + "time" + + vcard "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/carddav" + "github.com/yourusername/caldav-server/internal/auth" + "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/store" +) + +// Backend implements carddav.Backend using a filesystem store. +type Backend struct { + cfg *config.Config + store *store.Store + logger *slog.Logger +} + +// NewBackend creates a CardDAV 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 /card/ prefix. +func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.Handler { + b := NewBackend(cfg, st, logger) + return &carddav.Handler{Backend: b} +} + +// -------- carddav.Backend interface -------- + +func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) { + p := auth.FromContext(ctx) + if p == nil { + return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + } + return principalPath(p.Username), nil +} + +func (b *Backend) AddressBookHomeSetPath(ctx context.Context) (string, error) { + p := auth.FromContext(ctx) + if p == nil { + return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + } + return cardHomePath(p.Username), nil +} + +func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, 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 books []carddav.AddressBook + for _, name := range user.AddressBooks { + if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil { + b.logger.Warn("ensuring address book directory", "book", name, "error", err) + continue + } + books = append(books, b.bookMeta(p.Username, name)) + } + + // Also include extra books that exist on disk + disk, _ := b.store.ListCollections(p.Username) + configured := make(map[string]bool) + for _, n := range user.AddressBooks { + configured["card-"+n] = true + } + for _, dir := range disk { + if strings.HasPrefix(dir, "card-") && !configured[dir] { + name := strings.TrimPrefix(dir, "card-") + books = append(books, b.bookMeta(p.Username, name)) + } + } + + return books, nil +} + +func (b *Backend) GetAddressBook(ctx context.Context, bookPath string) (*carddav.AddressBook, error) { + user, name, err := b.parseBookPath(ctx, bookPath) + if err != nil { + return nil, err + } + if _, err := b.store.GetCollection(user, "card-"+name); err != nil { + return nil, webdav.NewHTTPError(http.StatusNotFound, err) + } + book := b.bookMeta(user, name) + return &book, nil +} + +func (b *Backend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + user, bookName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + + data, err := b.store.GetObject(user, "card-"+bookName, objID) + if err != nil { + return nil, webdav.NewHTTPError(http.StatusNotFound, err) + } + + return b.decodeObject(objPath, data) +} + +func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + user, bookName, err := b.parseBookPath(ctx, bookPath) + if err != nil { + return nil, err + } + + ids, err := b.store.ListObjects(user, "card-"+bookName) + if err != nil { + return nil, err + } + + var objs []carddav.AddressObject + for _, id := range ids { + data, err := b.store.GetObject(user, "card-"+bookName, id) + if err != nil { + continue + } + obj, err := b.decodeObject(cardObjectPath(user, bookName, id), data) + if err != nil { + b.logger.Warn("decoding vcard object", "id", id, "error", err) + continue + } + objs = append(objs, *obj) + } + return objs, nil +} + +func (b *Backend) QueryAddressObjects(ctx context.Context, bookPath string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + all, err := b.ListAddressObjects(ctx, bookPath, &query.DataRequest) + if err != nil { + return nil, err + } + return carddav.Filter(query, all) +} + +func (b *Backend) CreateAddressBook(ctx context.Context, book *carddav.AddressBook) error { + p := auth.FromContext(ctx) + if p == nil { + return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + } + name := path.Base(strings.TrimSuffix(book.Path, "/")) + return b.store.EnsureCollection(p.Username, "card-"+name) +} + +func (b *Backend) DeleteAddressBook(ctx context.Context, bookPath string) error { + user, name, err := b.parseBookPath(ctx, bookPath) + if err != nil { + return err + } + return b.store.DeleteCollection(user, "card-"+name) +} + +func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { + user, bookName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + + var buf strings.Builder + enc := vcard.NewEncoder(&buf) + if err := enc.Encode(card); err != nil { + return nil, fmt.Errorf("encoding vcard: %w", err) + } + + data := []byte(buf.String()) + if err := b.store.PutObject(user, "card-"+bookName, objID, data); err != nil { + return nil, fmt.Errorf("storing address object: %w", err) + } + + return b.decodeObject(objPath, data) +} + +func (b *Backend) DeleteAddressObject(ctx context.Context, objPath string) error { + user, bookName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return err + } + if err := b.store.DeleteObject(user, "card-"+bookName, objID); err != nil { + return webdav.NewHTTPError(http.StatusNotFound, err) + } + return nil +} + +// -------- helpers -------- + +func (b *Backend) bookMeta(user, name string) carddav.AddressBook { + return carddav.AddressBook{ + Path: cardHomePath(user) + name + "/", + Name: name, + Description: fmt.Sprintf("%s's %s address book", user, name), + MaxResourceSize: 10 * 1024 * 1024, + } +} + +func (b *Backend) decodeObject(objPath string, data []byte) (*carddav.AddressObject, error) { + dec := vcard.NewDecoder(strings.NewReader(string(data))) + card, err := dec.Decode() + if err != nil { + return nil, fmt.Errorf("decoding vcard: %w", err) + } + + etag := fmt.Sprintf(`"%x"`, hashBytes(data)) + + return &carddav.AddressObject{ + Path: objPath, + ModTime: time.Now(), + ContentLength: int64(len(data)), + ETag: etag, + Card: card, + }, nil +} + +func (b *Backend) parseBookPath(ctx context.Context, bookPath string) (user, bookName 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(bookPath, "/"), "/") + // expected: card/// + if len(parts) < 3 { + return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid address book path")) + } + bookName = parts[2] + return user, bookName, nil +} + +func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, bookName, 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: card/// + if len(parts) < 4 { + return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path")) + } + bookName = parts[2] + objID = path.Base(objPath) + return user, bookName, objID, nil +} + +func principalPath(user string) string { + return fmt.Sprintf("/principals/%s/", user) +} + +func cardHomePath(user string) string { + return fmt.Sprintf("/card/%s/", user) +} + +func cardObjectPath(user, bookName, objID string) string { + return fmt.Sprintf("/card/%s/%s/%s", user, bookName, objID) +} + +func hashBytes(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h ^= uint64(b) + h *= 1099511628211 + } + return h +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d343c91 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// 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"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + // Base URL used in DAV responses (e.g. https://dav.example.com) + BaseURL string `yaml:"base_url"` +} + +type AuthConfig struct { + // Realm shown in WWW-Authenticate header + Realm string `yaml:"realm"` +} + +type StorageConfig struct { + // Root directory for all data + DataDir string `yaml:"data_dir"` +} + +type UserConfig struct { + // bcrypt-hashed password (use `htpasswd -nB `) + Password string `yaml:"password"` + DisplayName string `yaml:"display_name"` + Email string `yaml:"email"` + Calendars []string `yaml:"calendars"` + AddressBooks []string `yaml:"address_books"` +} + +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +type LoggingConfig struct { + Level string `yaml:"level"` // debug | info | warn | error + Format string `yaml:"format"` // text | json +} + +// Load reads and parses a YAML config file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %q: %w", path, err) + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", path, err) + } + + cfg.applyDefaults() + + return cfg, cfg.validate() +} + +func (c *Config) applyDefaults() { + if c.Server.Host == "" { + c.Server.Host = "0.0.0.0" + } + if c.Server.Port == 0 { + c.Server.Port = 8080 + } + if c.Server.BaseURL == "" { + scheme := "http" + if c.TLS.Enabled { + scheme = "https" + } + c.Server.BaseURL = fmt.Sprintf("%s://%s:%d", scheme, c.Server.Host, c.Server.Port) + } + if c.Auth.Realm == "" { + c.Auth.Realm = "DAV Server" + } + if c.Storage.DataDir == "" { + c.Storage.DataDir = "./data" + } + if c.Logging.Level == "" { + c.Logging.Level = "info" + } + if c.Logging.Format == "" { + c.Logging.Format = "text" + } +} + +func (c *Config) validate() error { + if c.TLS.Enabled { + if c.TLS.CertFile == "" || c.TLS.KeyFile == "" { + return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled") + } + } + if len(c.Users) == 0 { + return fmt.Errorf("at least one user must be configured") + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1f7e121 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,179 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// ErrNotFound is returned when a resource does not exist. +var ErrNotFound = errors.New("not found") + +// ErrConflict is returned when trying to create a resource that already exists. +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. +type Store struct { + rootDir string + mu sync.RWMutex +} + +// NewStore creates or opens a Store rooted at rootDir. +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 +} + +// collectionPath returns the filesystem path for a collection. +func (s *Store) collectionPath(user, collection string) string { + return filepath.Join(s.rootDir, sanitize(user), sanitize(collection)) +} + +// objectPath returns the filesystem path for an object within a collection. +func (s *Store) objectPath(user, collection, objectID string) string { + return filepath.Join(s.collectionPath(user, collection), sanitize(objectID)) +} + +// 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() + 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() + + userDir := filepath.Join(s.rootDir, sanitize(user)) + entries, err := os.ReadDir(userDir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + return names, nil +} + +// GetCollection returns metadata about a collection. +func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + info, err := os.Stat(s.collectionPath(user, collection)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return info, err +} + +// 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() + + dir := s.collectionPath(user, collection) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + path := s.objectPath(user, collection, objectID) + // Write to a temp file then rename for atomicity. + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("writing object: %w", err) + } + return os.Rename(tmp, path) +} + +// GetObject reads an object's raw bytes. +func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := os.ReadFile(s.objectPath(user, collection, objectID)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return data, err +} + +// DeleteObject removes an object. +func (s *Store) DeleteObject(user, collection, objectID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + err := os.Remove(s.objectPath(user, collection, objectID)) + if errors.Is(err, os.ErrNotExist) { + return ErrNotFound + } + return err +} + +// ListObjects returns all object filenames in a collection. +func (s *Store) ListObjects(user, collection string) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + dir := s.collectionPath(user, collection) + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + var names []string + for _, e := range entries { + if !e.IsDir() && !strings.HasSuffix(e.Name(), ".tmp") { + names = append(names, e.Name()) + } + } + return names, nil +} + +// StatObject returns FileInfo for an object. +func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + info, err := os.Stat(s.objectPath(user, collection, objectID)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return info, err +} + +// DeleteCollection removes an entire collection directory. +func (s *Store) DeleteCollection(user, collection string) error { + s.mu.Lock() + defer s.mu.Unlock() + err := os.RemoveAll(s.collectionPath(user, collection)) + return err +} + +// sanitize removes path-traversal characters from a path segment. +func sanitize(s string) string { + s = filepath.Base(s) + s = strings.ReplaceAll(s, "..", "") + if s == "." || s == "" { + return "_" + } + return s +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2c18985 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,79 @@ +package store_test + +import ( + "os" + "testing" + + "github.com/yourusername/caldav-server/internal/store" +) + +func TestStoreRoundTrip(t *testing.T) { + dir := t.TempDir() + st, err := store.NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + const ( + user = "alice" + col = "cal-personal" + objectID = "event-001.ics" + ) + + data := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n") + + // Write + if err := st.PutObject(user, col, objectID, data); err != nil { + t.Fatalf("PutObject: %v", err) + } + + // Read + got, err := st.GetObject(user, col, objectID) + if err != nil { + t.Fatalf("GetObject: %v", err) + } + if string(got) != string(data) { + t.Errorf("data mismatch: got %q, want %q", got, data) + } + + // List + ids, err := st.ListObjects(user, col) + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if len(ids) != 1 || ids[0] != objectID { + t.Errorf("unexpected ids: %v", ids) + } + + // Delete + if err := st.DeleteObject(user, col, objectID); err != nil { + t.Fatalf("DeleteObject: %v", err) + } + + // Not found + _, err = st.GetObject(user, col, objectID) + if err != store.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestSanitizePath(t *testing.T) { + dir := t.TempDir() + st, err := store.NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + // Traversal attempts should not escape rootDir + dangerous := "../../../etc/passwd" + data := []byte("test") + if err := st.PutObject("user", "col", dangerous, data); err != nil { + t.Fatalf("PutObject with dangerous ID: %v", err) + } + + // Verify file was NOT written outside rootDir + _, err = os.Stat("/etc/passwd.tmp") + if err == nil { + t.Fatal("path traversal succeeded — security issue!") + } +} diff --git a/internal/webdav/handler.go b/internal/webdav/handler.go new file mode 100644 index 0000000..58e22a4 --- /dev/null +++ b/internal/webdav/handler.go @@ -0,0 +1,49 @@ +package filewebdav + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + + "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//. +func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := auth.FromContext(r.Context()) + if p == nil { + w.WriteHeader(http.StatusUnauthorized) + 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 + } + + // 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), + } + h.ServeHTTP(w, r) + }) +} diff --git a/tools/hashpwd/main.go b/tools/hashpwd/main.go new file mode 100644 index 0000000..c4a2413 --- /dev/null +++ b/tools/hashpwd/main.go @@ -0,0 +1,29 @@ +// tools/hashpwd generates a bcrypt hash for use in config.yaml. +// +// Usage: +// +// go run ./tools/hashpwd mysecretpassword +package main + +import ( + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: hashpwd ") + os.Exit(1) + } + + password := os.Args[1] + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Println(string(hash)) +}