diff --git a/carddav/carddav.go b/carddav/carddav.go index fe2913b..d0870a0 100644 --- a/carddav/carddav.go +++ b/carddav/carddav.go @@ -3,11 +3,13 @@ package carddav import ( "bytes" "errors" + "fmt" "io" "io/ioutil" "net/http" - "os" + "path" "strconv" + "strings" "sync" "time" @@ -17,9 +19,8 @@ import ( "golang.org/x/crypto/openpgp" ) -type contextKey string - -const ClientContextKey = contextKey("client") +// TODO: use a HTTP error +var errNotFound = errors.New("carddav: not found") var ( cleartextCardProps = []string{vcard.FieldVersion, vcard.FieldProductID, "X-PM-LABEL", "X-PM-GROUP"} @@ -80,52 +81,25 @@ func formatCard(card vcard.Card, privateKey *openpgp.Entity) (*protonmail.Contac return &contactImport, nil } -type addressFileInfo struct { - contact *protonmail.Contact +func parseAddressObjectPath(p string) (string, error) { + dirname, filename := path.Split(p) + ext := path.Ext(filename) + if dirname != "/" || ext != ".vcf" { + return "", errNotFound + } + return strings.TrimSuffix(filename, ext), nil } -func (fi *addressFileInfo) Name() string { - return fi.contact.ID +func formatAddressObjectPath(id string) string { + return "/" + id + ".vcf" } -func (fi *addressFileInfo) Size() int64 { - return int64(fi.contact.Size) -} +func (b *backend) toAddressObject(contact *protonmail.Contact, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + // TODO: handle req -func (fi *addressFileInfo) Mode() os.FileMode { - return os.ModePerm -} - -func (fi *addressFileInfo) ModTime() time.Time { - return time.Unix(fi.contact.ModifyTime, 0) -} - -func (fi *addressFileInfo) IsDir() bool { - return false -} - -func (fi *addressFileInfo) Sys() interface{} { - return nil -} - -type addressObject struct { - ab *addressBook - contact *protonmail.Contact -} - -func (ao *addressObject) ID() string { - return ao.contact.ID -} - -func (ao *addressObject) Stat() (os.FileInfo, error) { - return &addressFileInfo{ao.contact}, nil -} - -func (ao *addressObject) Card() (vcard.Card, error) { card := make(vcard.Card) - - for _, c := range ao.contact.Cards { - md, err := c.Read(ao.ab.privateKeys) + for _, c := range contact.Cards { + md, err := c.Read(b.privateKeys) if err != nil { return nil, err } @@ -149,80 +123,93 @@ func (ao *addressObject) Card() (vcard.Card, error) { } } - return card, nil + return &carddav.AddressObject{ + Path: formatAddressObjectPath(contact.ID), + ModTime: time.Unix(contact.ModifyTime, 0), + // TODO: stronger ETag + ETag: fmt.Sprintf("%x%x", contact.ModifyTime, contact.Size), + Card: card, + }, nil } -func (ao *addressObject) SetCard(card vcard.Card) error { - contactImport, err := formatCard(card, ao.ab.privateKeys[0]) - if err != nil { - return err - } - - contact, err := ao.ab.c.UpdateContact(ao.contact.ID, contactImport) - if err != nil { - return err - } - contact.Cards = contactImport.Cards // Not returned by the server - - ao.contact = contact - return nil -} - -func (ao *addressObject) Remove() error { - resps, err := ao.ab.c.DeleteContacts([]string{ao.contact.ID}) - if err != nil { - return err - } - if len(resps) != 1 { - return errors.New("hydroxide/carddav: expected exactly one response when deleting contact") - } - resp := resps[0] - return resp.Err() -} - -type addressBook struct { +type backend struct { c *protonmail.Client - cache map[string]*addressObject + cache map[string]*protonmail.Contact locker sync.Mutex total int privateKeys openpgp.EntityList } -func (ab *addressBook) Info() (*carddav.AddressBookInfo, error) { - return &carddav.AddressBookInfo{ +func (b *backend) AddressBook() (*carddav.AddressBook, error) { + return &carddav.AddressBook{ + Path: "/", Name: "ProtonMail", Description: "ProtonMail contacts", MaxResourceSize: 100 * 1024, }, nil } -func (ab *addressBook) cacheComplete() bool { - ab.locker.Lock() - defer ab.locker.Unlock() - return ab.total >= 0 && len(ab.cache) == ab.total +func (b *backend) cacheComplete() bool { + b.locker.Lock() + defer b.locker.Unlock() + return b.total >= 0 && len(b.cache) == b.total } -func (ab *addressBook) addressObject(id string) (*addressObject, bool) { - ab.locker.Lock() - defer ab.locker.Unlock() - ao, ok := ab.cache[id] - return ao, ok +func (b *backend) getCache(id string) (*protonmail.Contact, bool) { + b.locker.Lock() + contact, ok := b.cache[id] + b.locker.Unlock() + return contact, ok } -func (ab *addressBook) cacheAddressObject(ao *addressObject) { - ab.locker.Lock() - defer ab.locker.Unlock() - ab.cache[ao.contact.ID] = ao +func (b *backend) putCache(contact *protonmail.Contact) { + b.locker.Lock() + b.cache[contact.ID] = contact + b.locker.Unlock() } -func (ab *addressBook) ListAddressObjects() ([]carddav.AddressObject, error) { - if ab.cacheComplete() { - ab.locker.Lock() - defer ab.locker.Unlock() +func (b *backend) deleteCache(id string) { + b.locker.Lock() + delete(b.cache, id) + b.locker.Unlock() +} - aos := make([]carddav.AddressObject, 0, len(ab.cache)) - for _, ao := range ab.cache { - aos = append(aos, ao) +func (b *backend) GetAddressObject(path string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + id, err := parseAddressObjectPath(path) + if err != nil { + return nil, err + } + + contact, ok := b.getCache(id) + if !ok { + if b.cacheComplete() { + return nil, errNotFound + } + + contact, err = b.c.GetContact(id) + if apiErr, ok := err.(*protonmail.APIError); ok && apiErr.Code == 13051 { + return nil, errNotFound + } else if err != nil { + return nil, err + } + b.putCache(contact) + } + + return b.toAddressObject(contact, req) +} + +func (b *backend) ListAddressObjects(req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + if b.cacheComplete() { + b.locker.Lock() + defer b.locker.Unlock() + + aos := make([]carddav.AddressObject, 0, len(b.cache)) + for _, contact := range b.cache { + ao, err := b.toAddressObject(contact, req) + if err != nil { + return nil, err + } + aos = append(aos, *ao) } return aos, nil @@ -230,48 +217,41 @@ func (ab *addressBook) ListAddressObjects() ([]carddav.AddressObject, error) { // Get a list of all contacts // TODO: paging support - total, contacts, err := ab.c.ListContacts(0, 0) + total, contacts, err := b.c.ListContacts(0, 0) if err != nil { return nil, err } - ab.locker.Lock() - ab.total = total - ab.locker.Unlock() + b.locker.Lock() + b.total = total + b.locker.Unlock() + m := make(map[string]*protonmail.Contact, total) for _, contact := range contacts { - if _, ok := ab.addressObject(contact.ID); !ok { - ab.cacheAddressObject(&addressObject{ - ab: ab, - contact: contact, - }) - } + m[contact.ID] = contact } // Get all contacts cards - var aos []carddav.AddressObject + aos := make([]carddav.AddressObject, 0, total) page := 0 for { - _, contacts, err := ab.c.ListContactsExport(page, 0) + _, contacts, err := b.c.ListContactsExport(page, 0) if err != nil { return nil, err } - if aos == nil { - aos = make([]carddav.AddressObject, 0, total) - } - - for _, contact := range contacts { - ao, ok := ab.addressObject(contact.ID) + for _, contactExport := range contacts { + contact, ok := m[contactExport.ID] if !ok { - ao = &addressObject{ - ab: ab, - contact: &protonmail.Contact{ID: contact.ID}, - } - ab.cacheAddressObject(ao) + continue } + contact.Cards = contactExport.Cards + b.putCache(contact) - ao.contact.Cards = contact.Cards - aos = append(aos, ao) + ao, err := b.toAddressObject(contact, req) + if err != nil { + return nil, err + } + aos = append(aos, *ao) } if len(aos) >= total || len(contacts) == 0 { @@ -283,79 +263,93 @@ func (ab *addressBook) ListAddressObjects() ([]carddav.AddressObject, error) { return aos, nil } -func (ab *addressBook) GetAddressObject(id string) (carddav.AddressObject, error) { - if ao, ok := ab.addressObject(id); ok { - return ao, nil - } else if ab.cacheComplete() { - return nil, carddav.ErrNotFound - } - - contact, err := ab.c.GetContact(id) - if err != nil { - // TODO: return carddav.ErrNotFound if appropriate - return nil, err - } - - ao := &addressObject{ - ab: ab, - contact: contact, - } - ab.cacheAddressObject(ao) - return ao, nil +func (b *backend) QueryAddressObjects(query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + panic("TODO") } -func (ab *addressBook) CreateAddressObject(card vcard.Card) (carddav.AddressObject, error) { - contactImport, err := formatCard(card, ab.privateKeys[0]) +func (b *backend) PutAddressObject(path string, card vcard.Card) (loc string, err error) { + id, err := parseAddressObjectPath(path) if err != nil { - return nil, err + return "", err } - resps, err := ab.c.CreateContacts([]*protonmail.ContactImport{contactImport}) + contactImport, err := formatCard(card, b.privateKeys[0]) if err != nil { - return nil, err + return "", err } - if len(resps) != 1 { - return nil, errors.New("hydroxide/carddav: expected exactly one response when creating contact") + + var contact *protonmail.Contact + + var req carddav.AddressDataRequest + if _, getErr := b.GetAddressObject(path, &req); getErr == nil { + contact, err = b.c.UpdateContact(id, contactImport) + if err != nil { + return "", err + } + } else { + resps, err := b.c.CreateContacts([]*protonmail.ContactImport{contactImport}) + if err != nil { + return "", err + } + if len(resps) != 1 { + return "", errors.New("hydroxide/carddav: expected exactly one response when creating contact") + } + resp := resps[0] + if err := resp.Err(); err != nil { + return "", err + } + contact = resp.Response.Contact } - resp := resps[0] - if err := resp.Err(); err != nil { - return nil, err - } - contact := resp.Response.Contact contact.Cards = contactImport.Cards // Not returned by the server - ao := &addressObject{ - ab: ab, - contact: contact, - } - ab.cacheAddressObject(ao) - return ao, nil + // TODO: increment b.total if necessary + b.putCache(contact) + return formatAddressObjectPath(contact.ID), nil } -func (ab *addressBook) receiveEvents(events <-chan *protonmail.Event) { +func (b *backend) DeleteAddressObject(path string) error { + id, err := parseAddressObjectPath(path) + if err != nil { + return err + } + resps, err := b.c.DeleteContacts([]string{id}) + if err != nil { + return err + } + if len(resps) != 1 { + return errors.New("hydroxide/carddav: expected exactly one response when deleting contact") + } + resp := resps[0] + // TODO: decrement b.total if necessary + b.deleteCache(id) + return resp.Err() +} + +func (b *backend) receiveEvents(events <-chan *protonmail.Event) { for event := range events { - ab.locker.Lock() + b.locker.Lock() if event.Refresh&protonmail.EventRefreshContacts != 0 { - ab.cache = make(map[string]*addressObject) - ab.total = -1 + b.cache = make(map[string]*protonmail.Contact) + b.total = -1 } else if len(event.Contacts) > 0 { for _, eventContact := range event.Contacts { switch eventContact.Action { case protonmail.EventCreate: - ab.total++ + if b.total >= 0 { + b.total++ + } fallthrough case protonmail.EventUpdate: - ab.cache[eventContact.ID] = &addressObject{ - ab: ab, - contact: eventContact.Contact, - } + b.cache[eventContact.ID] = eventContact.Contact case protonmail.EventDelete: - delete(ab.cache, eventContact.ID) - ab.total-- + delete(b.cache, eventContact.ID) + if b.total >= 0 { + b.total-- + } } } } - ab.locker.Unlock() + b.locker.Unlock() } } @@ -364,16 +358,16 @@ func NewHandler(c *protonmail.Client, privateKeys openpgp.EntityList, events <-c panic("hydroxide/carddav: no private key available") } - ab := &addressBook{ + b := &backend{ c: c, - cache: make(map[string]*addressObject), + cache: make(map[string]*protonmail.Contact), total: -1, privateKeys: privateKeys, } if events != nil { - go ab.receiveEvents(events) + go b.receiveEvents(events) } - return carddav.NewHandler(ab) + return &carddav.Handler{b} } diff --git a/go.mod b/go.mod index b02c2a6..9c6cde1 100644 --- a/go.mod +++ b/go.mod @@ -5,19 +5,17 @@ go 1.13 require ( github.com/boltdb/bolt v1.3.1 github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 - github.com/emersion/go-imap v1.0.2 + github.com/emersion/go-imap v1.0.3 github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 github.com/emersion/go-message v0.11.1 - github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b // indirect github.com/emersion/go-smtp v0.12.1 github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7 - github.com/emersion/go-webdav v0.1.0 + github.com/emersion/go-webdav v0.2.1-0.20200203205455-3ea3818dd842 github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c github.com/kr/pretty v0.1.0 // indirect github.com/stretchr/testify v1.4.0 // indirect - golang.org/x/crypto v0.0.0-20200117160349-530e935923ad - golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa // indirect - golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c // indirect + golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72 + golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index e7267fd..ffbebac 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= -github.com/emersion/go-imap v1.0.2 h1:Uf+Bv8SeV067xYBWyo7rLZ7ZbkrAXLSc1G+2IVDp5JU= -github.com/emersion/go-imap v1.0.2/go.mod h1:TjT+1ncDso8j/VXeUHcZeQknho5hjyQLqEIybJJjjDI= +github.com/emersion/go-imap v1.0.3 h1:5eEee8/DTSIPfliiWqwfvjPGkU8bBtvOy/Wx+eeXzO4= +github.com/emersion/go-imap v1.0.3/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 h1:5p1t3e1PomYgLWwEwhwEU5kVBwcyAcVrOpexv8AeZx0= github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w= github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 h1:4ZAfwfc8aDlj26kkEap1UDSwwDnJp9Ie8Uj1MSXAkPk= github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4= -github.com/emersion/go-message v0.10.8 h1:1l1Vb+0By9U1ITTH3FgKfJQWQ9sTI3N1smPe6SS3QXY= -github.com/emersion/go-message v0.10.8/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= github.com/emersion/go-message v0.11.1 h1:0C/S4JIXDTSfXB1vpqdimAYyK4+79fgEAMQ0dSL+Kac= github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e h1:ba7YsgX5OV8FjGi5ZWml8Jng6oBrJAb3ahqWMJ5Ce8Q= @@ -24,12 +22,10 @@ github.com/emersion/go-smtp v0.12.1 h1:1R8BDqrR2HhlGwgFYcOi+BVTvK1bMjAB65QcVpJ5s github.com/emersion/go-smtp v0.12.1/go.mod h1:SD9V/xa4ndMw77lR3Mf7htkp8RBNYuPh9UeuBs9tpUQ= github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= -github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5 h1:n9qx98xiS5V4x2WIpPC2rr9mUM5ri9r/YhCEKbhCHro= -github.com/emersion/go-vcard v0.0.0-20190105225839-8856043f13c5/go.mod h1:WIi9g8OKJQHXtQbx7GExlo6UAFaui9WDMYabJ+Be4WI= github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7 h1:SE+tcd+0kn0cT4MqTo66gmkjqWHF1Z+Yha5/rhLs/H8= github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= -github.com/emersion/go-webdav v0.1.0 h1:d45qVFZebAj8Elzk59pfb4DgIjW3bmXjtzvmD83mjQw= -github.com/emersion/go-webdav v0.1.0/go.mod h1:PDMbYd9nLfHDxQw18jkbpGCCYO8gzByT3M1VnG9L05c= +github.com/emersion/go-webdav v0.2.1-0.20200203205455-3ea3818dd842 h1:CkiVEfQsswQP265LwFfylFiMhQVNQx0LrROT6dlv9ZQ= +github.com/emersion/go-webdav v0.2.1-0.20200203205455-3ea3818dd842/go.mod h1:5xPS/W9bnqkHMLtgygkgGFvm6uAsbeVRSIZk0ewu4d4= github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c h1:aY2hhxLhjEAbfXOx2nRJxCXezC6CO2V/yN+OCr1srtk= github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -48,17 +44,13 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200117160349-530e935923ad h1:Jh8cai0fqIK+f6nG0UgPW5wFk8wmiMhM3AyciDBdtQg= -golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72 h1:+ELyKg6m8UBf0nPFSqD0mi7zUfwPyXo23HNjMnXPz7w= +golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c h1:gUYreENmqtjZb2brVfUas1sC6UivSY8XwKwPo8tloLs= -golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=