438 lines
12 KiB
Go
438 lines
12 KiB
Go
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:displayname/>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
|
|
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
|
<d:prop>
|
|
<d:getetag/>
|
|
<card:address-data/>
|
|
</d:prop>
|
|
</card:addressbook-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 (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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
|
<d:prop>
|
|
<d:getetag/>
|
|
<card:address-data/>
|
|
</d:prop>
|
|
<card:filter>
|
|
<card:prop-filter name="FN">
|
|
<card:text-match collation="i;unicode-casemap" match-type="contains">%s</card:text-match>
|
|
</card:prop-filter>
|
|
</card:filter>
|
|
</card:addressbook-query>`, 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(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
b.WriteString(`<d:sync-collection xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">`)
|
|
if syncToken != "" {
|
|
b.WriteString("<d:sync-token>")
|
|
b.WriteString(xmlEscape(syncToken))
|
|
b.WriteString("</d:sync-token>")
|
|
}
|
|
b.WriteString(`<d:sync-level>1</d:sync-level>`)
|
|
b.WriteString(`<d:prop><d:getetag/><card:address-data/></d:prop>`)
|
|
b.WriteString(`</d:sync-collection>`)
|
|
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"`
|
|
}
|