This commit is contained in:
2026-04-23 21:56:59 +02:00
commit 7a11b5bbbf
17 changed files with 1710 additions and 0 deletions
+102
View File
@@ -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 }
+282
View File
@@ -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
}
+280
View File
@@ -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
}
+112
View File
@@ -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
}
+179
View File
@@ -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
}
+79
View File
@@ -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!")
}
}
+49
View File
@@ -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)
})
}