cardav working (tm)
This commit is contained in:
11
config.yaml
11
config.yaml
@@ -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
1
go.mod
@@ -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
2
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 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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
56
main.go
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user