This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
450 lines
14 KiB
Go
450 lines
14 KiB
Go
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)
|
|
}
|