cardav working (tm)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user