package service import ( "context" "crypto/md5" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/pkg/types" ) // CardDAVService handles CardDAV principal, address book, and contact operations type CardDAVService struct { pool *pgxpool.Pool logger zerolog.Logger } // NewCardDAVService creates a new CardDAV service func NewCardDAVService(pool *pgxpool.Pool, logger zerolog.Logger) *CardDAVService { return &CardDAVService{ pool: pool, logger: logger.With().Str("service", "carddav").Logger(), } } // --- Principals --- // ListPrincipals lists all principals func (s *CardDAVService) ListPrincipals(ctx context.Context) ([]types.CardDAVPrincipal, error) { rows, err := s.pool.Query(ctx, `SELECT id, uri, email, displayname FROM principals ORDER BY id`) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } defer rows.Close() principals := make([]types.CardDAVPrincipal, 0) for rows.Next() { var p types.CardDAVPrincipal var email, displayName *string if err := rows.Scan(&p.ID, &p.URI, &email, &displayName); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } if email != nil { p.Email = *email } if displayName != nil { p.DisplayName = *displayName } principals = append(principals, p) } return principals, nil } // GetPrincipal gets a principal by username func (s *CardDAVService) GetPrincipal(ctx context.Context, username string) (*types.CardDAVPrincipal, error) { uri := "principals/" + username var p types.CardDAVPrincipal var email, displayName *string err := s.pool.QueryRow(ctx, `SELECT id, uri, email, displayname FROM principals WHERE uri = $1`, uri). Scan(&p.ID, &p.URI, &email, &displayName) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("query failed: %w", err) } if email != nil { p.Email = *email } if displayName != nil { p.DisplayName = *displayName } return &p, nil } // CreatePrincipal creates a new principal func (s *CardDAVService) CreatePrincipal(ctx context.Context, req *types.CardDAVPrincipalCreate) (*types.CardDAVPrincipal, error) { uri := "principals/" + req.Username var id int err := s.pool.QueryRow(ctx, `INSERT INTO principals (uri, email, displayname) VALUES ($1, $2, $3) RETURNING id`, uri, nilIfEmpty(req.Email), nilIfEmpty(req.DisplayName)).Scan(&id) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } s.logger.Info().Str("username", req.Username).Int("id", id).Msg("Created principal") return s.GetPrincipal(ctx, req.Username) } // DeletePrincipal deletes a principal and cascades to address books and contacts func (s *CardDAVService) DeletePrincipal(ctx context.Context, username string) error { uri := "principals/" + username tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin tx failed: %w", err) } defer tx.Rollback(ctx) // Delete contacts and changes for all address books owned by this principal _, err = tx.Exec(ctx, `DELETE FROM cards WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri) if err != nil { return fmt.Errorf("delete contacts failed: %w", err) } _, err = tx.Exec(ctx, `DELETE FROM addressbookchanges WHERE addressbookid IN (SELECT id FROM addressbooks WHERE principaluri = $1)`, uri) if err != nil { return fmt.Errorf("delete changes failed: %w", err) } // Delete address books _, err = tx.Exec(ctx, `DELETE FROM addressbooks WHERE principaluri = $1`, uri) if err != nil { return fmt.Errorf("delete addressbooks failed: %w", err) } // Delete principal ct, err := tx.Exec(ctx, `DELETE FROM principals WHERE uri = $1`, uri) if err != nil { return fmt.Errorf("delete principal failed: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("principal not found") } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit failed: %w", err) } s.logger.Info().Str("username", username).Msg("Deleted principal with cascade") return nil } // --- Address Books --- // ListAddressBooks lists address books, optionally filtered by principal func (s *CardDAVService) ListAddressBooks(ctx context.Context, principal string) ([]types.AddressBook, error) { query := `SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks` args := []interface{}{} if principal != "" { query += ` WHERE principaluri = $1` args = append(args, "principals/"+principal) } query += ` ORDER BY id` rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } defer rows.Close() books := make([]types.AddressBook, 0) for rows.Next() { var ab types.AddressBook var description *string if err := rows.Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } if description != nil { ab.Description = *description } books = append(books, ab) } return books, nil } // GetAddressBook gets an address book by ID func (s *CardDAVService) GetAddressBook(ctx context.Context, id int) (*types.AddressBook, error) { var ab types.AddressBook var description *string err := s.pool.QueryRow(ctx, `SELECT id, principaluri, displayname, uri, description, synctoken FROM addressbooks WHERE id = $1`, id). Scan(&ab.ID, &ab.PrincipalURI, &ab.DisplayName, &ab.URI, &description, &ab.SyncToken) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("query failed: %w", err) } if description != nil { ab.Description = *description } return &ab, nil } // CreateAddressBook creates a new address book func (s *CardDAVService) CreateAddressBook(ctx context.Context, req *types.AddressBookCreate) (*types.AddressBook, error) { var id int err := s.pool.QueryRow(ctx, `INSERT INTO addressbooks (principaluri, displayname, uri, description, synctoken) VALUES ($1, $2, $3, $4, 1) RETURNING id`, req.PrincipalURI, req.DisplayName, req.URI, nilIfEmpty(req.Description)).Scan(&id) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } s.logger.Info().Int("id", id).Str("uri", req.URI).Msg("Created address book") return s.GetAddressBook(ctx, id) } // UpdateAddressBook updates an address book func (s *CardDAVService) UpdateAddressBook(ctx context.Context, id int, req *types.AddressBookUpdate) (*types.AddressBook, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.DisplayName != nil { setClauses = append(setClauses, fmt.Sprintf("displayname = $%d", argIdx)) args = append(args, *req.DisplayName) argIdx++ } if req.Description != nil { setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx)) args = append(args, *req.Description) argIdx++ } if len(setClauses) == 0 { return s.GetAddressBook(ctx, id) } args = append(args, id) query := fmt.Sprintf("UPDATE addressbooks SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) ct, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } if ct.RowsAffected() == 0 { return nil, nil } return s.GetAddressBook(ctx, id) } // DeleteAddressBook deletes an address book and its contacts func (s *CardDAVService) DeleteAddressBook(ctx context.Context, id int) error { tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin tx failed: %w", err) } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, `DELETE FROM cards WHERE addressbookid = $1`, id) if err != nil { return fmt.Errorf("delete contacts failed: %w", err) } _, err = tx.Exec(ctx, `DELETE FROM addressbookchanges WHERE addressbookid = $1`, id) if err != nil { return fmt.Errorf("delete changes failed: %w", err) } ct, err := tx.Exec(ctx, `DELETE FROM addressbooks WHERE id = $1`, id) if err != nil { return fmt.Errorf("delete addressbook failed: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("address book not found") } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit failed: %w", err) } s.logger.Info().Int("id", id).Msg("Deleted address book with contacts") return nil } // --- Contacts --- // ListContacts lists contacts in an address book (metadata only, no carddata) func (s *CardDAVService) ListContacts(ctx context.Context, addressBookID int) ([]types.Contact, error) { rows, err := s.pool.Query(ctx, `SELECT id, addressbookid, uri, lastmodified, etag, size FROM cards WHERE addressbookid = $1 ORDER BY id`, addressBookID) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } defer rows.Close() contacts := make([]types.Contact, 0) for rows.Next() { var c types.Contact if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.LastModified, &c.ETag, &c.Size); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } contacts = append(contacts, c) } return contacts, nil } // GetContact gets a contact by address book ID and URI (returns full carddata) func (s *CardDAVService) GetContact(ctx context.Context, addressBookID int, uri string) (*types.Contact, error) { var c types.Contact var cardData []byte err := s.pool.QueryRow(ctx, `SELECT id, addressbookid, carddata, uri, lastmodified, etag, size FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri). Scan(&c.ID, &c.AddressBookID, &cardData, &c.URI, &c.LastModified, &c.ETag, &c.Size) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("query failed: %w", err) } c.CardData = string(cardData) return &c, nil } // CreateContact creates a new contact in an address book func (s *CardDAVService) CreateContact(ctx context.Context, addressBookID int, req *types.ContactCreate) (*types.Contact, error) { etag := computeETag(req.CardData) size := len(req.CardData) lastModified := int(time.Now().Unix()) tx, err := s.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx failed: %w", err) } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, `INSERT INTO cards (addressbookid, carddata, uri, lastmodified, etag, size) VALUES ($1, $2, $3, $4, $5, $6)`, addressBookID, []byte(req.CardData), req.URI, lastModified, etag, size) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } // Record change and bump sync token (operation 1 = add) if err := addChange(ctx, tx, addressBookID, req.URI, 1); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit failed: %w", err) } s.logger.Info().Int("addressbookId", addressBookID).Str("uri", req.URI).Msg("Created contact") return s.GetContact(ctx, addressBookID, req.URI) } // UpdateContact updates a contact's vCard data func (s *CardDAVService) UpdateContact(ctx context.Context, addressBookID int, uri string, req *types.ContactUpdate) (*types.Contact, error) { etag := computeETag(req.CardData) size := len(req.CardData) lastModified := int(time.Now().Unix()) tx, err := s.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx failed: %w", err) } defer tx.Rollback(ctx) ct, err := tx.Exec(ctx, `UPDATE cards SET carddata = $1, lastmodified = $2, etag = $3, size = $4 WHERE addressbookid = $5 AND uri = $6`, []byte(req.CardData), lastModified, etag, size, addressBookID, uri) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } if ct.RowsAffected() == 0 { return nil, nil } // Record change and bump sync token (operation 2 = modify) if err := addChange(ctx, tx, addressBookID, uri, 2); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit failed: %w", err) } s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Updated contact") return s.GetContact(ctx, addressBookID, uri) } // DeleteContact deletes a contact from an address book func (s *CardDAVService) DeleteContact(ctx context.Context, addressBookID int, uri string) error { tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin tx failed: %w", err) } defer tx.Rollback(ctx) ct, err := tx.Exec(ctx, `DELETE FROM cards WHERE addressbookid = $1 AND uri = $2`, addressBookID, uri) if err != nil { return fmt.Errorf("delete failed: %w", err) } if ct.RowsAffected() == 0 { return fmt.Errorf("contact not found") } // Record change and bump sync token (operation 3 = delete) if err := addChange(ctx, tx, addressBookID, uri, 3); err != nil { return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit failed: %w", err) } s.logger.Info().Int("addressbookId", addressBookID).Str("uri", uri).Msg("Deleted contact") return nil } // addChange records a change in addressbookchanges and bumps the sync token. // This is critical for CardDAV sync — without it, clients won't see incremental changes. // Operations: 1=add, 2=modify, 3=delete func addChange(ctx context.Context, tx pgx.Tx, addressBookID int, uri string, operation int) error { _, err := tx.Exec(ctx, `INSERT INTO addressbookchanges (uri, synctoken, addressbookid, operation) SELECT $1, synctoken, $2, $3 FROM addressbooks WHERE id = $2`, uri, addressBookID, operation) if err != nil { return fmt.Errorf("record change failed: %w", err) } _, err = tx.Exec(ctx, `UPDATE addressbooks SET synctoken = synctoken + 1 WHERE id = $1`, addressBookID) if err != nil { return fmt.Errorf("bump synctoken failed: %w", err) } return nil } // computeETag computes the ETag for card data (raw MD5 hex, matching sabre/dav DB format) func computeETag(cardData string) string { hash := md5.Sum([]byte(cardData)) return fmt.Sprintf("%x", hash) }