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.*?>.*?[a-zA-Z0-9]*:?response>`)
+ reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)[a-zA-Z0-9]*:?href>`)
+ rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?[a-zA-Z0-9]*:?propstat>`)
+ reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK[a-zA-Z0-9]*:?status>`)
+ reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?[a-zA-Z0-9]*:?prop>`)
+ rePropClose := regexp.MustCompile(`[a-zA-Z0-9]*:?prop>`)
+
+ 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).*?>.*?[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(`[a-zA-Z0-9]*:?response>`)
+ 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,