From e0796a071b3784f7c7dbc16124f2bd52490e1a2d Mon Sep 17 00:00:00 2001 From: "Lennart J. Kurzweg (Nx2)" Date: Tue, 31 Mar 2026 02:08:39 +0200 Subject: [PATCH] cardav working (tm) --- config.yaml | 11 ++ go.mod | 1 + go.sum | 2 + internal/backend/db.go | 325 +++++++++++++++++++++++++++++++++++++- internal/config/config.go | 29 ++-- internal/extra/color.go | 85 +++++++++- main.go | 102 ++++++++---- 7 files changed, 503 insertions(+), 52 deletions(-) diff --git a/config.yaml b/config.yaml index 3db4090..b8ff970 100644 --- a/config.yaml +++ b/config.yaml @@ -48,6 +48,17 @@ calendars: owner: shared color: '#999999' +address_books: + - id: contacts + owner: lennart + - id: contacts + owner: daniel + - id: family + owner: shared + access: + - group: family + mode: read-write + aggregates: - access: - group: family diff --git a/go.mod b/go.mod index 4353797..2295200 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index 7ad43a1..fde74fd 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegN github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= +github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/backend/db.go b/internal/backend/db.go index a631b10..ecfc51c 100644 --- a/internal/backend/db.go +++ b/internal/backend/db.go @@ -15,9 +15,12 @@ import ( "time" "github.com/emersion/go-ical" + "github.com/emersion/go-vcard" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" + "github.com/emersion/go-webdav/carddav" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "nxcaldav/internal/config" @@ -105,15 +108,35 @@ func (b *DBBackend) initSchema(ctx context.Context) error { PRIMARY KEY (calendar_id, user_id) )`, `CREATE TABLE IF NOT EXISTS calendar_objects ( - id SERIAL PRIMARY KEY, - calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, - path TEXT NOT NULL, - data TEXT NOT NULL, - etag TEXT NOT NULL, - UNIQUE (calendar_id, path) + id SERIAL PRIMARY KEY, + calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, + path TEXT NOT NULL, + data TEXT NOT NULL, + etag TEXT NOT NULL, + UNIQUE (calendar_id, path) )`, - } - + `CREATE TABLE IF NOT EXISTS addressbooks ( + id SERIAL PRIMARY KEY, + owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + path TEXT UNIQUE NOT NULL, + name TEXT, + description TEXT + )`, + `CREATE TABLE IF NOT EXISTS addressbook_access ( + addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + mode TEXT NOT NULL, + PRIMARY KEY (addressbook_id, user_id) + )`, + `CREATE TABLE IF NOT EXISTS addressbook_objects ( + id SERIAL PRIMARY KEY, + addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE, + path TEXT NOT NULL, + data TEXT NOT NULL, + etag TEXT NOT NULL, + UNIQUE (addressbook_id, path) + )`, + } for _, q := range queries { if _, err := b.pool.Exec(ctx, q); err != nil { return fmt.Errorf("failed to execute schema query: %v", err) @@ -264,6 +287,65 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { } } + // --- Phase 2.5: Address Book & Access Sync --- + configAddressBookPaths := make(map[string]bool) + for _, ab := range cfg.AddressBooks { + path := prefix + fmt.Sprintf("/%s/addressbooks/%s/", ab.Owner, ab.ID) + configAddressBookPaths[path] = true + + var ownerID int + err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", ab.Owner).Scan(&ownerID) + if err != nil { + return fmt.Errorf("owner %s not found: %v", ab.Owner, err) + } + + var abID int + err = tx.QueryRow(ctx, ` + INSERT INTO addressbooks (owner_id, path, name) VALUES ($1, $2, $3) + ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name + RETURNING id`, + ownerID, path, ab.ID).Scan(&abID) + if err != nil { + return err + } + + // Re-build access rules for this address book + _, err = tx.Exec(ctx, "DELETE FROM addressbook_access WHERE addressbook_id = $1", abID) + if err != nil { + return err + } + + addressBookAccessModes := make(map[string]string) + for _, a := range ab.Access { + var tUsers []string + if a.User != "" { + tUsers = append(tUsers, a.User) + } + if a.Group != "" { + tUsers = append(tUsers, groupMembers[a.Group]...) + } + if a.Groups != "" { + tUsers = append(tUsers, groupMembers[a.Groups]...) + } + for _, u := range tUsers { + addressBookAccessModes[u] = a.Mode + } + } + + for uName, mode := range addressBookAccessModes { + var userID int + err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", uName).Scan(&userID) + if err != nil { + return fmt.Errorf("access user %s not found: %v", uName, err) + } + _, err = tx.Exec(ctx, "INSERT INTO addressbook_access (addressbook_id, user_id, mode) VALUES ($1, $2, $3)", + abID, userID, mode) + if err != nil { + return err + } + } + } + // --- Phase 3: Aggregate Setup --- // Aggregates are virtual, so we only track them in memory for routing. for _, agg := range cfg.Aggregates { @@ -1182,4 +1264,231 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string { return color } return "" + } + + // --- CardDAV Backend Implementation --- + + func (b *DBBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { + username, err := b.getUsername(ctx) + if err != nil { + return "", err + } + return b.prefix + fmt.Sprintf("/%s/addressbooks/", username), nil + } + + func (b *DBBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) { + username, err := b.getUsername(ctx) + if err != nil { + return nil, err + } + + rows, err := b.pool.Query(ctx, ` + SELECT path, name, COALESCE(description, '') FROM addressbooks + WHERE owner_id = (SELECT id FROM users WHERE name = $1) + OR id IN (SELECT addressbook_id FROM addressbook_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`, + username) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []carddav.AddressBook + for rows.Next() { + var ab carddav.AddressBook + if err := rows.Scan(&ab.Path, &ab.Name, &ab.Description); err != nil { + return nil, err + } + ab.MaxResourceSize = 1000000 + ab.SupportedAddressData = []carddav.AddressDataType{ + {ContentType: "text/vcard", Version: "3.0"}, + {ContentType: "text/vcard", Version: "4.0"}, + } + res = append(res, ab) + } + return res, nil + } + + func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) { + if !strings.HasSuffix(p, "/") { + p += "/" + } + + var ab carddav.AddressBook + err := b.pool.QueryRow(ctx, "SELECT path, name, COALESCE(description, '') FROM addressbooks WHERE path = $1", p).Scan(&ab.Path, &ab.Name, &ab.Description) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found")) + } + return nil, err + } + ab.MaxResourceSize = 1000000 + ab.SupportedAddressData = []carddav.AddressDataType{ + {ContentType: "text/vcard", Version: "3.0"}, + {ContentType: "text/vcard", Version: "4.0"}, + } + return &ab, nil + } + + func (b *DBBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book creation only via config")) + } + + func (b *DBBackend) DeleteAddressBook(ctx context.Context, p string) error { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book deletion only via config")) + } + + func (b *DBBackend) checkAddressBookAccess(ctx context.Context, abPath string, requiredMode string) (int, string, error) { + username, err := b.getUsername(ctx) + if err != nil { + return 0, "", err + } + + if !strings.HasSuffix(abPath, "/") { + abPath += "/" + } + + var abID int + var ownerName string + err = b.pool.QueryRow(ctx, ` + SELECT a.id, u.name + FROM addressbooks a + JOIN users u ON a.owner_id = u.id + WHERE a.path = $1`, abPath).Scan(&abID, &ownerName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found")) + } + return 0, "", err + } + + if ownerName == username { + return abID, "owner", nil + } + + var mode string + err = b.pool.QueryRow(ctx, ` + SELECT mode FROM addressbook_access + WHERE addressbook_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`, + abID, username).Scan(&mode) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied")) + } + return 0, "", err + } + + if requiredMode == "write" && mode != "read-write" { + return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) + } + return abID, mode, nil + } + + func (b *DBBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + if !strings.HasSuffix(p, "/") { + p += "/" + } + + abID, _, err := b.checkAddressBookAccess(ctx, p, "read") + if err != nil { + return nil, err + } + + rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1", abID) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []carddav.AddressObject + for rows.Next() { + var obj carddav.AddressObject + var dataStr string + if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil { + return nil, err + } + + card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode() + if err != nil { + continue + } + obj.Card = card + res = append(res, obj) + } + return res, nil } + +func (b *DBBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + dirPath := path.Dir(p) + "/" + abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "read") + if err != nil { + return nil, err + } + + var obj carddav.AddressObject + var dataStr string + err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p).Scan(&obj.Path, &dataStr, &obj.ETag) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found")) + } + return nil, err + } + + card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode() + if err != nil { + return nil, err + } + obj.Card = card + return &obj, nil +} + +func (b *DBBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { + dirPath := path.Dir(p) + "/" + abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write") + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := vcard.NewEncoder(&buf).Encode(card); err != nil { + return nil, err + } + dataStr := buf.String() + etag := fmt.Sprintf(`"%d"`, len(dataStr)) + + _, err = b.pool.Exec(ctx, ` + INSERT INTO addressbook_objects (addressbook_id, path, data, etag) VALUES ($1, $2, $3, $4) + ON CONFLICT (addressbook_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`, + abID, p, dataStr, etag) + if err != nil { + return nil, err + } + + return &carddav.AddressObject{ + Path: p, + Card: card, + ETag: etag, + }, nil +} + +func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error { + dirPath := path.Dir(p) + "/" + abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write") + if err != nil { + return err + } + + commandTag, err := b.pool.Exec(ctx, "DELETE FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p) + if err != nil { + return err + } + if commandTag.RowsAffected() == 0 { + return webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found")) + } + return nil +} + +func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + return b.ListAddressObjects(ctx, p, nil) +} + diff --git a/internal/config/config.go b/internal/config/config.go index 2ed4cbc..2a60239 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,13 +18,20 @@ type Access struct { } type Calendar struct { - ID string `yaml:"id"` - Owner string `yaml:"owner"` - Color string `yaml:"color,omitempty"` - Access []Access `yaml:"access,omitempty"` + ID string `yaml:"id"` + Owner string `yaml:"owner"` + Color string `yaml:"color,omitempty"` + Access []Access `yaml:"access,omitempty"` +} + +type AddressBook struct { + ID string `yaml:"id"` + Owner string `yaml:"owner"` + Access []Access `yaml:"access,omitempty"` } type Aggregate struct { + ID string `yaml:"id"` Owner string `yaml:"owner"` Color string `yaml:"color,omitempty"` @@ -67,14 +74,14 @@ type SMTPConfig struct { } type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - SMTP SMTPConfig `yaml:"smtp"` - Users []User `yaml:"users"` - Calendars []Calendar `yaml:"calendars"` - Aggregates []Aggregate `yaml:"aggregates"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + SMTP SMTPConfig `yaml:"smtp"` + Users []User `yaml:"users"` + Calendars []Calendar `yaml:"calendars"` + AddressBooks []AddressBook `yaml:"address_books"` + Aggregates []Aggregate `yaml:"aggregates"` } - func Load(path string) (*Config, error) { f, err := os.Open(path) if err != nil { diff --git a/internal/extra/color.go b/internal/extra/color.go index f33bf7b..60b52bd 100644 --- a/internal/extra/color.go +++ b/internal/extra/color.go @@ -6,7 +6,9 @@ import ( "fmt" "context" "io" + "log" "net/http" + "nxcaldav/internal/backend" "regexp" "time" @@ -99,8 +101,87 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht return resp }) - // Headers are already set in h.ResponseWriter.Header() by caldav.Handler - // But Content-Length might have changed + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) + w.WriteHeader(rw.status) + w.Write(body) +} + +func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) { + reqBody, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + + buf := &bytes.Buffer{} + rw := &responseWriter{w, buf, http.StatusOK} + handler.ServeHTTP(rw, r.WithContext(ctx)) + + body := buf.Bytes() + + // 1. Unconditionally add namespaces to root tag + reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`) + body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav"`)) + + // 2. Response processing + reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?`) + reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)`) + rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?`) + reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK`) + reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?`) + rePropClose := regexp.MustCompile(``) + + body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte { + hrefMatch := reHref.FindSubmatch(resp) + if len(hrefMatch) < 2 { + return resp + } + + calHome, _ := be.CalendarHomeSetPath(ctx) + cardHome, _ := be.AddressBookHomeSetPath(ctx) + + // Strip these tags ONLY from non-200 propstats + resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte { + if !reStatusOk.Match(ps) { + reTags := regexp.MustCompile(`(?s)<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?/>|<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?>.*?`) + return reTags.ReplaceAll(ps, []byte("")) + } + return ps + }) + + props := "" + if calHome != "" && !strings.Contains(string(resp), "calendar-home-set") { + props += fmt.Sprintf("%s", calHome) + } + if cardHome != "" && !strings.Contains(string(resp), "addressbook-home-set") { + props += fmt.Sprintf("%s", cardHome) + } + + if props == "" { + return resp + } + + has200 := false + resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte { + if reStatusOk.Match(ps) { + has200 = true + return reProp.ReplaceAllFunc(ps, func(prop []byte) []byte { + return rePropClose.ReplaceAllFunc(prop, func(closeTag []byte) []byte { + return append([]byte(props), closeTag...) + }) + }) + } + return ps + }) + + if !has200 { + newPropstat := fmt.Sprintf("%sHTTP/1.1 200 OK", props) + reResponseClose := regexp.MustCompile(``) + resp = reResponseClose.ReplaceAllFunc(resp, func(closeTag []byte) []byte { + return append([]byte(newPropstat), closeTag...) + }) + } + + return resp + }) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) w.WriteHeader(rw.status) w.Write(body) diff --git a/main.go b/main.go index 37159d9..bb81485 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,20 @@ package main import ( - "context" - "fmt" - "log" - "os" + "context" + "fmt" + "log" + "os" + "net/http" "net/url" "strings" "time" "github.com/emersion/go-webdav/caldav" + "github.com/emersion/go-webdav/carddav" "nxcaldav/internal/backend" + "nxcaldav/internal/extra" "nxcaldav/internal/config" ) @@ -36,8 +39,8 @@ func main() { log.Fatalf("failed to initialize database backend: %v", err) } - handler := &caldav.Handler{Backend: be} - + caldavHandler := &caldav.Handler{Backend: be} + carddavHandler := &carddav.Handler{Backend: be} publicURL, _ := url.Parse(cfg.Server.PublicURL) http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) { @@ -114,35 +117,72 @@ func main() { return } - log.Printf("[user: %s] %s %s ", user, r.Method, r.URL.Path) - principalPath := prefix + fmt.Sprintf("/%s/", user) - ctx := context.WithValue(r.Context(), "principal", principalPath) + log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path) - if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" { - // If normalized request, use normalized host/scheme for redirect - if publicURL != nil && publicURL.Host != "" { - scheme := r.URL.Scheme - if scheme == "" { - scheme = "http" - } - target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath) - http.Redirect(w, r, target, http.StatusMovedPermanently) - } else { - http.Redirect(w, r, principalPath, http.StatusMovedPermanently) - } - return - } + principalPath := prefix + fmt.Sprintf("/%s/", user) + // Add addressbook to DAV header for discovery/access paths + // This includes principal path and .well-known + isCardDAVPath := strings.Contains(r.URL.Path, "/addressbooks/") || + r.URL.Path == "/.well-known/carddav" || + r.URL.Path == prefix+"/.well-known/carddav" || + r.URL.Path == principalPath || + r.URL.Path == strings.TrimSuffix(principalPath, "/") + + if isCardDAVPath { + w.Header().Add("DAV", "addressbook") + } - // needed because color info is not RFC, so regex hack - if r.Method == "PROPFIND" { - extra.AddColorToCalendarPropfind(r, ctx, handler, w, be) - } else { - handler.ServeHTTP(w, r.WithContext(ctx)) - } - }) + ctx := context.WithValue(r.Context(), "principal", principalPath) - fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress) + if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" || + r.URL.Path == "/.well-known/carddav" || r.URL.Path == prefix+"/.well-known/carddav" { + // If normalized request, use normalized host/scheme for redirect + if publicURL != nil && publicURL.Host != "" { + scheme := r.URL.Scheme + if scheme == "" { + scheme = "http" + } + target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath) + http.Redirect(w, r, target, http.StatusMovedPermanently) + } else { + http.Redirect(w, r, principalPath, http.StatusMovedPermanently) + } + return + } + + // needed because color info is not RFC, so regex hack + if strings.Contains(r.URL.Path, "/calendars/") { + if r.Method == "PROPFIND" { + extra.AddColorToCalendarPropfind(r, ctx, caldavHandler, w, be) + } else { + caldavHandler.ServeHTTP(w, r.WithContext(ctx)) + } + } else if strings.Contains(r.URL.Path, "/addressbooks/") { + carddavHandler.ServeHTTP(w, r.WithContext(ctx)) + } else { + // Fallback: try both or default to caldav for principal path etc. + if strings.HasSuffix(r.URL.Path, user+"/") || strings.HasSuffix(r.URL.Path, user) { + // For principal path, use merged discovery handler + if r.Method == "PROPFIND" { + extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be) + } else { + caldavHandler.ServeHTTP(w, r.WithContext(ctx)) + // Ensure DAV header includes addressbook for OPTIONS + if r.Method == "OPTIONS" { + dav := w.Header().Get("DAV") + if dav != "" && !strings.Contains(dav, "addressbook") { + w.Header().Set("DAV", dav+", addressbook") + } + } + } + } else { + http.NotFound(w, r) + } + } + }) + + fmt.Printf("Starting CalDAV/CardDAV server on %s...\n", cfg.Server.BindAddress) server := &http.Server{ Addr: cfg.Server.BindAddress, ReadTimeout: 30 * time.Second,