package nextcloud
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var ErrSyncTokenInvalid = errors.New("nextcloud contacts sync token invalid")
type AddressBook struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Path string `json:"path"`
}
type Contact struct {
UID string `json:"uid"`
FullName string `json:"full_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Org string `json:"org"`
Path string `json:"path,omitempty"`
ETag string `json:"etag,omitempty"`
RawVCard string `json:"raw_vcard,omitempty"`
}
// ContactSyncResult is the delta from a CardDAV sync-collection REPORT.
type ContactSyncResult struct {
SyncToken string `json:"sync_token"`
Contacts []Contact `json:"contacts"`
Deleted []string `json:"deleted"`
}
func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) {
path := addressBookHomePath(userID)
body := `
`
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := readResponseBody(resp)
if err != nil {
return nil, err
}
if err := davResponseError(raw, resp.StatusCode); err != nil {
return nil, err
}
return parseAddressBookList(strings.NewReader(string(raw)), path)
}
func (c *Client) ListContacts(ctx context.Context, userID, bookPath string) ([]Contact, error) {
body := `
`
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := readResponseBody(resp)
if err != nil {
return nil, err
}
if err := davResponseError(raw, resp.StatusCode); err != nil {
return nil, err
}
return parseContactList(strings.NewReader(string(raw)))
}
func (c *Client) SyncContacts(ctx context.Context, userID, bookPath, syncToken string) (ContactSyncResult, error) {
body := buildSyncCollectionRequest(syncToken)
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return ContactSyncResult{}, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusForbidden:
return ContactSyncResult{}, ErrSyncTokenInvalid
case http.StatusOK, http.StatusMultiStatus:
return parseContactSyncResponse(resp.Body)
default:
return ContactSyncResult{}, &HTTPStatusError{Operation: "sync contacts", StatusCode: resp.StatusCode}
}
}
func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error {
vcard := buildVCard(contact)
uid := contact.UID
if uid == "" {
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
}
contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid)
resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, map[string]string{
"Content-Type": "text/vcard; charset=utf-8",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 201 && resp.StatusCode != 204 {
return fmt.Errorf("create contact failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *Contact) (string, error) {
vcard := buildVCard(contact)
headers := map[string]string{
"Content-Type": "text/vcard; charset=utf-8",
}
if strings.TrimSpace(ifMatch) != "" {
headers["If-Match"] = strings.TrimSpace(ifMatch)
}
resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, headers)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
return "", ErrETagMismatch
}
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("update contact failed: %d", resp.StatusCode)
}
return strings.TrimSpace(resp.Header.Get("ETag")), nil
}
func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*Contact, error) {
resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get contact failed: %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
contact := parseVCard(string(raw))
contact.Path = contactPath
contact.ETag = strings.TrimSpace(resp.Header.Get("ETag"))
contact.RawVCard = string(raw)
return &contact, nil
}
func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
return fmt.Errorf("delete contact failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query string) ([]Contact, error) {
body := fmt.Sprintf(`
%s
`, query)
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := readResponseBody(resp)
if err != nil {
return nil, err
}
if err := davResponseError(raw, resp.StatusCode); err != nil {
return nil, err
}
return parseContactList(strings.NewReader(string(raw)))
}
func buildVCard(contact *Contact) string {
var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n")
b.WriteString("VERSION:3.0\r\n")
if contact.UID != "" {
b.WriteString(fmt.Sprintf("UID:%s\r\n", contact.UID))
}
b.WriteString(fmt.Sprintf("FN:%s\r\n", contact.FullName))
if contact.Email != "" {
b.WriteString(fmt.Sprintf("EMAIL;TYPE=INTERNET:%s\r\n", contact.Email))
}
if contact.Phone != "" {
b.WriteString(fmt.Sprintf("TEL;TYPE=CELL:%s\r\n", contact.Phone))
}
if contact.Org != "" {
b.WriteString(fmt.Sprintf("ORG:%s\r\n", contact.Org))
}
b.WriteString("END:VCARD\r\n")
return b.String()
}
func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error) {
var ms multistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
basePath = normalizeDAVHref(basePath)
books := make([]AddressBook, 0)
for _, r := range ms.Responses {
href := normalizeDAVHref(r.Href)
if href == basePath {
continue
}
name := r.Propstat.Prop.DisplayName
if name == "" {
continue
}
books = append(books, AddressBook{
ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
DisplayName: name,
Path: href,
})
}
return books, nil
}
func normalizeDAVHref(href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "/cloud/") {
return strings.TrimPrefix(href, "/cloud")
}
return href
}
func buildSyncCollectionRequest(syncToken string) string {
var b strings.Builder
b.WriteString(``)
b.WriteString(``)
if syncToken != "" {
b.WriteString("")
b.WriteString(xmlEscape(syncToken))
b.WriteString("")
}
b.WriteString(`1`)
b.WriteString(``)
b.WriteString(``)
return b.String()
}
func xmlEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func parseContactList(body io.Reader) ([]Contact, error) {
var ms cardMultistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
contacts := make([]Contact, 0, len(ms.Responses))
for _, r := range ms.Responses {
if isAddressBookCollectionHref(r.Href) {
continue
}
contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop)
if !ok {
continue
}
contacts = append(contacts, contact)
}
return contacts, nil
}
func parseContactSyncResponse(body io.Reader) (ContactSyncResult, error) {
var ms cardSyncMultistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return ContactSyncResult{}, err
}
out := ContactSyncResult{
SyncToken: strings.TrimSpace(ms.SyncToken),
Contacts: make([]Contact, 0, len(ms.Responses)),
Deleted: make([]string, 0),
}
for _, r := range ms.Responses {
if isAddressBookCollectionHref(r.Href) {
continue
}
if isDeletedSyncResponse(r) {
out.Deleted = append(out.Deleted, r.Href)
continue
}
contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop)
if !ok {
continue
}
out.Contacts = append(out.Contacts, contact)
}
return out, nil
}
func contactFromCardProp(href string, prop cardProp) (Contact, bool) {
vcard := strings.TrimSpace(prop.AddressData)
if vcard == "" {
return Contact{}, false
}
contact := parseVCard(vcard)
contact.Path = href
contact.ETag = normalizeETag(prop.ETag)
contact.RawVCard = vcard
return contact, true
}
func normalizeETag(etag string) string {
return strings.TrimSpace(etag)
}
func isAddressBookCollectionHref(href string) bool {
return strings.HasSuffix(href, "/")
}
func isDeletedSyncResponse(r cardSyncResponse) bool {
if statusIndicatesDeleted(r.Status) {
return true
}
return statusIndicatesDeleted(r.Propstat.Status)
}
func statusIndicatesDeleted(status string) bool {
return strings.Contains(status, "404")
}
func parseVCard(vcard string) Contact {
var c Contact
for _, line := range strings.Split(vcard, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "UID:"):
c.UID = strings.TrimPrefix(line, "UID:")
case strings.HasPrefix(line, "FN:"):
c.FullName = strings.TrimPrefix(line, "FN:")
case strings.HasPrefix(line, "EMAIL"):
if idx := strings.LastIndex(line, ":"); idx >= 0 {
c.Email = line[idx+1:]
}
case strings.HasPrefix(line, "TEL"):
if idx := strings.LastIndex(line, ":"); idx >= 0 {
c.Phone = line[idx+1:]
}
case strings.HasPrefix(line, "ORG:"):
c.Org = strings.TrimPrefix(line, "ORG:")
}
}
return c
}
type cardMultistatus struct {
XMLName xml.Name `xml:"multistatus"`
Responses []cardResponse `xml:"response"`
}
type cardResponse struct {
Href string `xml:"href"`
Propstat cardPropstat `xml:"propstat"`
}
type cardSyncMultistatus struct {
XMLName xml.Name `xml:"multistatus"`
SyncToken string `xml:"sync-token"`
Responses []cardSyncResponse `xml:"response"`
}
type cardSyncResponse struct {
Href string `xml:"href"`
Status string `xml:"status"`
Propstat cardPropstat `xml:"propstat"`
}
type cardPropstat struct {
Status string `xml:"status"`
Prop cardProp `xml:"prop"`
}
type cardProp struct {
ETag string `xml:"getetag"`
AddressData string `xml:"address-data"`
}