wip
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
+25
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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$<hash generated above>"
|
||||||
|
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/<user>/` | CalDAV home |
|
||||||
|
| `/cal/<user>/<calendar>/` | Calendar collection |
|
||||||
|
| `/card/<user>/` | CardDAV home |
|
||||||
|
| `/card/<user>/<book>/` | Address book collection |
|
||||||
|
| `/files/<user>/` | 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:
|
||||||
|
<username>:
|
||||||
|
password: "<bcrypt hash>"
|
||||||
|
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 |
|
||||||
@@ -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 = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>DAV Server</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
ul { line-height: 2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🗓 DAV Server</h1>
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
<h2>Auto-discovery</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>%s/.well-known/caldav</code></li>
|
||||||
|
<li><code>%s/.well-known/carddav</code></li>
|
||||||
|
</ul>
|
||||||
|
<p><em>Authentication: HTTP Basic Auth</em></p>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
+34
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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 }
|
||||||
@@ -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/<user>/<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/<user>/<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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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/<user>/<bookname>/
|
||||||
|
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/<user>/<bookname>/<objid>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <user>`)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<username>/.
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 <password>")
|
||||||
|
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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user