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"` }