cardav working (tm)

This commit is contained in:
Lennart J. Kurzweg (Nx2)
2026-03-31 02:08:39 +02:00
parent ce78c6e07f
commit e0796a071b
7 changed files with 503 additions and 52 deletions

View File

@@ -48,6 +48,17 @@ calendars:
owner: shared owner: shared
color: '#999999' color: '#999999'
address_books:
- id: contacts
owner: lennart
- id: contacts
owner: daniel
- id: family
owner: shared
access:
- group: family
mode: read-write
aggregates: aggregates:
- access: - access:
- group: family - group: family

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
) )
require ( require (
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect

2
go.sum
View File

@@ -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 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= 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-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 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -15,9 +15,12 @@ import (
"time" "time"
"github.com/emersion/go-ical" "github.com/emersion/go-ical"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"nxcaldav/internal/config" "nxcaldav/internal/config"
@@ -112,8 +115,28 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
etag TEXT NOT NULL, etag TEXT NOT NULL,
UNIQUE (calendar_id, path) 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 { for _, q := range queries {
if _, err := b.pool.Exec(ctx, q); err != nil { if _, err := b.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("failed to execute schema query: %v", err) 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 --- // --- Phase 3: Aggregate Setup ---
// Aggregates are virtual, so we only track them in memory for routing. // Aggregates are virtual, so we only track them in memory for routing.
for _, agg := range cfg.Aggregates { for _, agg := range cfg.Aggregates {
@@ -1182,4 +1264,231 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string {
return color return color
} }
return "" 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)
}

View File

@@ -24,7 +24,14 @@ type Calendar struct {
Access []Access `yaml:"access,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 { type Aggregate struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Color string `yaml:"color,omitempty"` Color string `yaml:"color,omitempty"`
@@ -72,9 +79,9 @@ type Config struct {
SMTP SMTPConfig `yaml:"smtp"` SMTP SMTPConfig `yaml:"smtp"`
Users []User `yaml:"users"` Users []User `yaml:"users"`
Calendars []Calendar `yaml:"calendars"` Calendars []Calendar `yaml:"calendars"`
AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"` Aggregates []Aggregate `yaml:"aggregates"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {

View File

@@ -6,7 +6,9 @@ import (
"fmt" "fmt"
"context" "context"
"io" "io"
"log"
"net/http" "net/http"
"nxcaldav/internal/backend" "nxcaldav/internal/backend"
"regexp" "regexp"
"time" "time"
@@ -99,8 +101,87 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
return resp return resp
}) })
// Headers are already set in h.ResponseWriter.Header() by caldav.Handler w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
// But Content-Length might have changed 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("<C:calendar-home-set><href xmlns=\"DAV:\">%s</href></C:calendar-home-set>", calHome)
}
if cardHome != "" && !strings.Contains(string(resp), "addressbook-home-set") {
props += fmt.Sprintf("<card:addressbook-home-set><href xmlns=\"DAV:\">%s</href></card:addressbook-home-set>", 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("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", 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.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(rw.status) w.WriteHeader(rw.status)
w.Write(body) w.Write(body)

56
main.go
View File

@@ -5,13 +5,16 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"nxcaldav/internal/backend" "nxcaldav/internal/backend"
"nxcaldav/internal/extra" "nxcaldav/internal/extra"
"nxcaldav/internal/config" "nxcaldav/internal/config"
) )
@@ -36,8 +39,8 @@ func main() {
log.Fatalf("failed to initialize database backend: %v", err) 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) publicURL, _ := url.Parse(cfg.Server.PublicURL)
http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) {
@@ -114,11 +117,26 @@ func main() {
return return
} }
log.Printf("[user: %s] %s %s ", user, r.Method, r.URL.Path) log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path)
principalPath := prefix + fmt.Sprintf("/%s/", user) 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")
}
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" { 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 normalized request, use normalized host/scheme for redirect
if publicURL != nil && publicURL.Host != "" { if publicURL != nil && publicURL.Host != "" {
scheme := r.URL.Scheme scheme := r.URL.Scheme
@@ -133,16 +151,38 @@ func main() {
return return
} }
// needed because color info is not RFC, so regex hack // needed because color info is not RFC, so regex hack
if strings.Contains(r.URL.Path, "/calendars/") {
if r.Method == "PROPFIND" { if r.Method == "PROPFIND" {
extra.AddColorToCalendarPropfind(r, ctx, handler, w, be) extra.AddColorToCalendarPropfind(r, ctx, caldavHandler, w, be)
} else { } else {
handler.ServeHTTP(w, r.WithContext(ctx)) 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 server on %s...\n", cfg.Server.BindAddress) fmt.Printf("Starting CalDAV/CardDAV server on %s...\n", cfg.Server.BindAddress)
server := &http.Server{ server := &http.Server{
Addr: cfg.Server.BindAddress, Addr: cfg.Server.BindAddress,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,