Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
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>
This commit is contained in:
449
internal/service/carddav.go
Normal file
449
internal/service/carddav.go
Normal file
@@ -0,0 +1,449 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user