forked from Silverfish/proton-bridge
GODT-35: New pmapi client and manager using resty
This commit is contained in:
@ -40,8 +40,8 @@ type Builder struct {
|
||||
}
|
||||
|
||||
type Fetcher interface {
|
||||
GetMessage(string) (*pmapi.Message, error)
|
||||
GetAttachment(string) (io.ReadCloser, error)
|
||||
GetMessage(context.Context, string) (*pmapi.Message, error)
|
||||
GetAttachment(context.Context, string) (io.ReadCloser, error)
|
||||
KeyRingForAddressID(string) (*crypto.KeyRing, error)
|
||||
}
|
||||
|
||||
|
||||
@ -1,309 +0,0 @@
|
||||
# Do not modify this file!
|
||||
It is here for historical reasons only. All changes should be documented in the
|
||||
Changelog at the root of this repository.
|
||||
|
||||
|
||||
# Changelog for API
|
||||
> NOTE we are using versioning for go-pmapi in format `major.minor.bugfix`
|
||||
> * major stays at version 1 for the forseeable future
|
||||
> * minor is increased when a force upgrade happens or in case of major breaking changes
|
||||
> * patch is increased when new features are added
|
||||
|
||||
## v1.0.16
|
||||
|
||||
### Fixed
|
||||
* Potential crash when reporting cert pin failure
|
||||
|
||||
## v1.0.15
|
||||
|
||||
### Changed
|
||||
* Merge only 50 events into one
|
||||
* Response header timeout increased from 10s to 30s
|
||||
|
||||
### Fixed
|
||||
* Make keyring unlocking threadsafe
|
||||
|
||||
## v1.0.14
|
||||
|
||||
### Added
|
||||
* Config for disabling TLS cert fingerprint checking
|
||||
|
||||
### Fixed
|
||||
* Ensure sensitive stuff is cleared on client logout even if requests fail
|
||||
|
||||
## v1.0.13
|
||||
|
||||
### Fixed
|
||||
* Correctly set Transport in http client
|
||||
|
||||
## v1.0.12
|
||||
|
||||
### Changed
|
||||
* Only `http.RoundTripper` interface is needed instead of full `http.Transport` struct
|
||||
|
||||
### Added
|
||||
* GODT-61 (and related): Use DoH to find and switch to a proxy server if the API becomes unreachable
|
||||
* GODT-67 added random wait to not cause spikes on server after StatusTooManyRequests
|
||||
|
||||
### Fixed
|
||||
* FirstReadTimeout was wrongly timeout of the whole request including repeating ones, now it's really only timeout for the first read
|
||||
|
||||
## v1.0.11
|
||||
|
||||
### Added
|
||||
* GODT-53 `Message.Type` added with constants `MessageType*`
|
||||
|
||||
## v1.0.10
|
||||
|
||||
### Added
|
||||
* GODT-55 exporting DANGEROUSLYSetUID
|
||||
|
||||
### Changed
|
||||
* The full communication between clien and API is logged if logrus level is trace
|
||||
|
||||
## v1.0.9
|
||||
|
||||
### Fixed
|
||||
* Use correct address type value (because API starts counting from 1 but we were counting from 0)
|
||||
|
||||
## v1.0.8
|
||||
|
||||
### Added
|
||||
* Introdcution of connection manager
|
||||
|
||||
### Fixed
|
||||
* Deadlock during the auth-refresh
|
||||
* Fixed an issue where some events were being discarded when merging
|
||||
|
||||
## v1.0.7
|
||||
|
||||
### Changed
|
||||
* The given access token is saved during auth refresh if none was available yet
|
||||
|
||||
|
||||
## v1.0.6
|
||||
|
||||
### Added
|
||||
* `ClientConfig.Timeout` to be able to configure the whole timeout of request
|
||||
* `ClientConfig.FirstReadTimeout` to be able to configure the timeout of request to the first byte
|
||||
* `ClientConfig.MinSpeed` to be able to configure the timeout when the connection is too slow (limitation in minimum bytes per second)
|
||||
* Set default timeouts for http.Transport with certificate pinning
|
||||
|
||||
### Changed
|
||||
* http.Client by default uses ProxyFromEnvironment to support HTTP_PROXY and HTTPS_PROXY environment variables
|
||||
|
||||
## v1.0.5
|
||||
|
||||
### Added
|
||||
* `ContentTypeMultipartEncrypted` MIME content type for encrypted email
|
||||
* `MessageCounts` in event struct
|
||||
|
||||
## v1.0.4
|
||||
|
||||
### Added
|
||||
* `PMKeys` for parsing and reading KeyRing
|
||||
* `clearableKey` to rewrite memory
|
||||
* Proton/backend-communication#25 Unlock with tokens (OneKey2RuleThemAll Phase I)
|
||||
|
||||
### Changed
|
||||
* Update of gopenpgp: convert JSON to KeyRing in PMAPI
|
||||
* `user.KeyRing` -> `user.KeyRing()`
|
||||
* typo `client.GetAddresses()`
|
||||
|
||||
### Removed
|
||||
* `address.KeyRing`
|
||||
|
||||
## v1.0.2 v1.0.3
|
||||
|
||||
### Changed
|
||||
* Fixed capitalisation in a few places
|
||||
* Added /metrics API route
|
||||
* Changed function names to be compliant with go linter
|
||||
* Encrypt with primary key only
|
||||
* Fix `client.doBuffered` - closing body before handling unauthorized request
|
||||
* go-pm-crypto -> GopenPGP
|
||||
* redefine old functions in `keyring.go`
|
||||
* `attachment.Decrypt` drops returning signature (does signature check by default)
|
||||
* `attachment.Encrypt` is using readers instead of writers
|
||||
* `attachment.DetachedSign` drops writer param and returns signature as a reader
|
||||
* `message.Decrypt` drops returning signature (does signature check by default)
|
||||
* Changed TLS report URL to https://reports.protonmail.ch/reports/tls
|
||||
* Moved from current to soon TLS pin
|
||||
|
||||
## v1.0.1
|
||||
|
||||
### Removed
|
||||
* `ClientID` from all auth routes
|
||||
* `ErrorDescription` from error
|
||||
|
||||
## v1.0.0
|
||||
|
||||
### Changed
|
||||
* `client.AuthInfo` does return 2FA information only when authenticated, for the first login information available in `Auth.HasTwoFactor`
|
||||
* `client.Auth` does not accept 2FA code in favor of `client.Auth2FA`
|
||||
* `client.Unlock` supports only new way of unlock with directly available access token
|
||||
|
||||
### Added
|
||||
* `Res.StatusCode` to pass HTTP status code to responses
|
||||
* `Auth.HasTwoFactor` method to determine whether account has enabled 2FA (same as `AuthInfo.HasTwoFactor`)
|
||||
* `Auth2FA*` structs for 2FA endpoint
|
||||
* `client.Auth2FA` method to fully unlock session with 2FA code
|
||||
* `ErrUnauthorized` when request cannot be authorized
|
||||
* `ErrBad2FACode` when bad 2FA and user cannot try again
|
||||
* `ErrBad2FACodeTryAgain` when bad 2FA but user can try again
|
||||
|
||||
## 2019-08-06
|
||||
|
||||
### Added
|
||||
* Send TLS issue report to API
|
||||
* Cert fingerpring with `TLSPinning` struct
|
||||
* Check API certificate fingerprint and verify hostname
|
||||
|
||||
### Changed
|
||||
* Using `AddressID` for `/messge/count` and `/conversations/count`
|
||||
* Less of copying of responses from the server in the memory
|
||||
|
||||
## 2019-08-01
|
||||
* low case for `sirupsen`
|
||||
* using go modules
|
||||
|
||||
## 2019-07-15
|
||||
|
||||
### Changed
|
||||
* `client.Auths` field is removed in favor of function `client.SetAuths` which opens possibility to use interface
|
||||
|
||||
## 2019-05-18
|
||||
|
||||
### Changed
|
||||
* proton/backend-communication#11 x-pm-uid sent always for `/auth/refresh`
|
||||
* proton/backend-communication#11 UID never changes
|
||||
|
||||
## 2019-05-28
|
||||
|
||||
### Added
|
||||
* New test server patern using callbacks
|
||||
* Responses are read from json files
|
||||
|
||||
### Changed
|
||||
* `auth_tests.go` to new callback server pattern
|
||||
* Linter fixes for tests
|
||||
|
||||
### Removed
|
||||
* `TestClient_Do_expired` due to no effect, use `DoUnauthorized` instead
|
||||
|
||||
## 2019-05-24
|
||||
* Help functions for test
|
||||
* CI with Lint
|
||||
|
||||
## 2019-05-23
|
||||
* Log userID
|
||||
|
||||
## 2019-05-21
|
||||
* Fix unlocking user keys
|
||||
|
||||
## 2019-04-25
|
||||
|
||||
### Changed
|
||||
* rename `Uid` -> `UID` proton/backend-communication#11
|
||||
|
||||
## 2019-04-09
|
||||
|
||||
### Added
|
||||
* sending attachments as zip `application/octet-stream`
|
||||
* function `ReportReq.AddAttachment()`
|
||||
* data memeber `ReportReq.Attachments`
|
||||
* general function to report bug `client.Report(req ReportReq)` with object as parameter
|
||||
|
||||
### Changed
|
||||
* `client.ReportBug` and `client.ReportBugWithClient` functions are obsolete and they uses `client.Report(req ReportReq)`
|
||||
* `client.ReportCrash` is obsolete. Use sentry instead
|
||||
* `Api`->`API`, `Uid`->`UID`
|
||||
|
||||
## 2019-03-13
|
||||
* user id in raven
|
||||
* add file position of panic sender
|
||||
|
||||
## 2019-03-06
|
||||
* #30 update `pm-crypto` to store `KeyRing.FirstKeyID`
|
||||
* #30 Add key salt to `Auth` object from `GetKeySalts` request
|
||||
* #30 Add route `GET /keys/salt`
|
||||
* removed unused `PmCrypto`
|
||||
|
||||
## 2019-02-20
|
||||
* removed unused `decryptAccessToken`
|
||||
|
||||
## 2019-01-21
|
||||
* #29 Parsing all goroutines from pprof
|
||||
* #29 Sentry `Threads` implementation
|
||||
* #29 using sentry for crashes
|
||||
|
||||
## 2019-01-07
|
||||
* refactor `pmapi.DecryptString` -> `pmcrypto.KeyRing.DecryptString`
|
||||
* fixed tests
|
||||
* `crypto` -> `pmcrypto`
|
||||
* refactoring code using repos `go-pm-crypto`, `go-pm-mime` and `go-srp`
|
||||
|
||||
|
||||
## 2018-12-10
|
||||
* #26 adding `Flags` field to message
|
||||
* #26 removing fields deprecated by `Flags`: `IsEncrypted`, `Type`, `IsReplied`, `IsRepliedAll`, `IsForwarded`
|
||||
* #26 removing deprecated consts (see #26 for replacement)
|
||||
* #26 fixing tests (compiling not working)
|
||||
|
||||
## 2018-11-19
|
||||
|
||||
### Added
|
||||
* Wait and retry from `DoJson` if banned from api
|
||||
|
||||
### Changed
|
||||
* `ErrNoInternet` -> `ErrAPINotReachable`
|
||||
* Adding codes for force upgrade: 5004 and 5005
|
||||
* Adding codes for API offline: 7001
|
||||
* Adding codes for BansRequests: 85131
|
||||
|
||||
## 2018-09-18
|
||||
|
||||
### Added
|
||||
* `client.decryptAccessToken` if privateKey is received (tested with local api) #23
|
||||
|
||||
### Changed
|
||||
* added fields to User
|
||||
* local config TLS skip verify
|
||||
|
||||
## 2018-09-06
|
||||
|
||||
### Changed
|
||||
* decrypt token only if needed
|
||||
|
||||
### Broken
|
||||
* Tests are not working
|
||||
|
||||
## APIv3 UPDATE (2018-08-01)
|
||||
* issue Desktop-Bridge#561
|
||||
|
||||
### Added
|
||||
* Key flag consts
|
||||
* `EventAddress`
|
||||
* `MailSettings` object and route call
|
||||
* `Client.KeyRingForAddressID`
|
||||
* `AuthInfo.HasTwoFactor()`
|
||||
* `Auth.HasMailboxPassword()`
|
||||
|
||||
### Changed
|
||||
* Addresses are part of client
|
||||
* Update user updates also addresses
|
||||
* `BodyKey` and `AttachmentKey` contains `Key` and `Algorithm`
|
||||
* `keyPair` (not use Pubkey) -> `pmKeyObject`
|
||||
* lots of indent
|
||||
* bugs route
|
||||
* two factor (ready to U2F)
|
||||
* Reorder some to match order in doc (easier to )
|
||||
* omit address Order when empty
|
||||
* update user and addresses in `CurrentUser()`
|
||||
* `User.Unlock()` -> `Client.UnlockAddresses()`
|
||||
* `AuthInfo.Uid` -> `AuthInfo.Uid()`
|
||||
* `User.Addresses` -> `Client.Addresses()`
|
||||
|
||||
### Removed
|
||||
* User v3 removed plenty (now in settings)
|
||||
* Message v3 removed plenty (Starred is label)
|
||||
@ -1,19 +0,0 @@
|
||||
export GO111MODULE=on
|
||||
|
||||
LINTVER="v1.21.0"
|
||||
LINTSRC="https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh"
|
||||
|
||||
check-has-go:
|
||||
@which go || (echo "Install Go-lang!" && exit 1)
|
||||
|
||||
install-dev-dependencies: install-linter
|
||||
|
||||
install-linter: check-has-go
|
||||
curl -sfL $(LINTSRC) | sh -s -- -b $(shell go env GOPATH)/bin $(LINTVER)
|
||||
|
||||
lint:
|
||||
which golangci-lint || $(MAKE) install-linter
|
||||
golangci-lint run ./... \
|
||||
|
||||
test:
|
||||
go test -run=${TESTRUN} ./...
|
||||
@ -18,10 +18,12 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Address statuses.
|
||||
@ -80,11 +82,6 @@ type Address struct {
|
||||
// AddressList is a list of addresses.
|
||||
type AddressList []*Address
|
||||
|
||||
type AddressesRes struct {
|
||||
Res
|
||||
Addresses AddressList
|
||||
}
|
||||
|
||||
// ByID returns an address by id. Returns nil if no address is found.
|
||||
func (l AddressList) ByID(id string) *Address {
|
||||
for _, addr := range l {
|
||||
@ -164,40 +161,22 @@ func ConstructAddress(headerEmail string, addressEmail string) string {
|
||||
}
|
||||
|
||||
// GetAddresses requests all of current user addresses (without pagination).
|
||||
func (c *client) GetAddresses() (addresses AddressList, err error) {
|
||||
req, err := c.NewRequest("GET", "/addresses", nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) GetAddresses(ctx context.Context) (addresses AddressList, err error) {
|
||||
var res struct {
|
||||
Addresses []*Address
|
||||
}
|
||||
|
||||
var res AddressesRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/addresses")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Addresses, res.Err()
|
||||
return res.Addresses, nil
|
||||
}
|
||||
|
||||
func (c *client) ReorderAddresses(addressIDs []string) (err error) {
|
||||
var reqBody struct {
|
||||
AddressIDs []string
|
||||
}
|
||||
|
||||
reqBody.AddressIDs = addressIDs
|
||||
|
||||
req, err := c.NewJSONRequest("PUT", "/addresses/order", reqBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var addContactsRes AddContactsResponse
|
||||
if err = c.DoJSON(req, &addContactsRes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.UpdateUser()
|
||||
|
||||
return
|
||||
func (c *client) ReorderAddresses(ctx context.Context, addressIDs []string) (err error) {
|
||||
panic("TODO")
|
||||
}
|
||||
|
||||
// Addresses returns the addresses stored in the client object itself rather than fetching from the API.
|
||||
|
||||
@ -21,13 +21,13 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type header textproto.MIMEHeader
|
||||
@ -138,12 +138,6 @@ func (a *Attachment) DetachedSign(kr *crypto.KeyRing, att io.Reader) (signed io.
|
||||
return signAttachment(kr, att)
|
||||
}
|
||||
|
||||
type CreateAttachmentRes struct {
|
||||
Res
|
||||
|
||||
Attachment *Attachment
|
||||
}
|
||||
|
||||
func writeAttachment(w *multipart.Writer, att *Attachment, r io.Reader, sig io.Reader) (err error) {
|
||||
// Create metadata fields.
|
||||
if err = w.WriteField("Filename", att.Name); err != nil {
|
||||
@ -185,91 +179,37 @@ func writeAttachment(w *multipart.Writer, att *Attachment, r io.Reader, sig io.R
|
||||
// CreateAttachment uploads an attachment. It must be already encrypted and contain a MessageID.
|
||||
//
|
||||
// The returned created attachment contains the new attachment ID and its size.
|
||||
func (c *client) CreateAttachment(att *Attachment, r io.Reader, sig io.Reader) (*Attachment, error) {
|
||||
req, w, err := c.NewMultipartRequest("POST", "/mail/v4/attachments")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (c *client) CreateAttachment(ctx context.Context, att *Attachment, attData io.Reader, sigData io.Reader) (*Attachment, error) {
|
||||
var res struct {
|
||||
Attachment *Attachment
|
||||
}
|
||||
|
||||
cx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
req = req.WithContext(cx)
|
||||
|
||||
// We will write the request as long as it is sent to the API.
|
||||
var res CreateAttachmentRes
|
||||
done := make(chan error, 1)
|
||||
go (func() {
|
||||
done <- c.DoJSON(req, &res)
|
||||
})()
|
||||
|
||||
if err := writeAttachment(w.Writer, att, r, sig); err != nil {
|
||||
_ = w.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := res.Err(); err != nil {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).
|
||||
SetMultipartFormData(map[string]string{
|
||||
"Filename": att.Name,
|
||||
"MessageID": att.MessageID,
|
||||
"MIMEType": att.MIMEType,
|
||||
"ContentID": att.ContentID,
|
||||
}).
|
||||
SetMultipartField("DataPacket", "DataPacket.pgp", "application/octet-stream", attData).
|
||||
SetMultipartField("Signature", "Signature.pgp", "application/octet-stream", sigData).
|
||||
Post("/mail/v4/attachments")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Attachment, nil
|
||||
}
|
||||
|
||||
type UpdateAttachmentSignatureReq struct {
|
||||
Signature string
|
||||
}
|
||||
|
||||
func (c *client) UpdateAttachmentSignature(attachmentID, signature string) (err error) {
|
||||
updateReq := &UpdateAttachmentSignatureReq{signature}
|
||||
req, err := c.NewJSONRequest("PUT", "/mail/v4/attachments/"+attachmentID+"/signature", updateReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteAttachment removes an attachment. message is the message ID, att is the attachment ID.
|
||||
func (c *client) DeleteAttachment(attID string) (err error) {
|
||||
req, err := c.NewRequest("DELETE", "/mail/v4/attachments/"+attID, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// GetAttachment gets an attachment's content. The returned data is encrypted.
|
||||
func (c *client) GetAttachment(id string) (att io.ReadCloser, err error) {
|
||||
if id == "" {
|
||||
err = errors.New("pmapi: cannot get an attachment with an empty id")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := c.NewRequest("GET", "/mail/v4/attachments/"+id, nil)
|
||||
func (c *client) GetAttachment(ctx context.Context, attachmentID string) (att io.ReadCloser, err error) {
|
||||
res, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetDoNotParseResponse(true).Get("/mail/v4/attachments/" + attachmentID)
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.Do(req, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
att = res.Body
|
||||
return
|
||||
return res.RawBody(), nil
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -94,7 +95,7 @@ func TestAttachment_UnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_CreateAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/mail/v4/attachments"))
|
||||
|
||||
contentType, params, err := pmmime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@ -136,12 +137,14 @@ func TestClient_CreateAttachment(t *testing.T) {
|
||||
t.Errorf("Invalid attachment packets: expected %v but got %v", testAttachment.KeyPackets, string(b))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testCreateAttachmentBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
r := strings.NewReader(testAttachmentCleartext) // In reality, this thing is encrypted
|
||||
created, err := c.CreateAttachment(testAttachment, r, strings.NewReader(""))
|
||||
created, err := c.CreateAttachment(context.TODO(), testAttachment, r, strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while creating attachment, got:", err)
|
||||
}
|
||||
@ -151,34 +154,17 @@ func TestClient_CreateAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DeleteAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/mail/v4/attachments/"+testAttachment.ID))
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
if n, _ := b.ReadFrom(r.Body); n != 0 {
|
||||
t.Fatal("expected no body but have: ", b.String())
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testDeleteAttachmentBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.DeleteAttachment(testAttachment.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while deleting attachment, got:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/mail/v4/attachments/"+testAttachment.ID))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testAttachmentCleartext)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
r, err := c.GetAttachment(testAttachment.ID)
|
||||
r, err := c.GetAttachment(context.TODO(), testAttachment.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting attachment, got:", err)
|
||||
}
|
||||
|
||||
@ -1,407 +1,47 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
var ErrBad2FACode = errors.New("incorrect 2FA code")
|
||||
var ErrBad2FACodeTryAgain = errors.New("incorrect 2FA code: please try again")
|
||||
|
||||
type AuthInfoReq struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
type U2FInfo struct {
|
||||
Challenge string
|
||||
RegisteredKeys []struct {
|
||||
Version string
|
||||
KeyHandle string
|
||||
}
|
||||
}
|
||||
|
||||
type TwoFactorInfo struct {
|
||||
Enabled int // 0 for disabled, 1 for OTP, 2 for U2F, 3 for both.
|
||||
TOTP int
|
||||
U2F U2FInfo
|
||||
}
|
||||
|
||||
func (twoFactor *TwoFactorInfo) hasTwoFactor() bool {
|
||||
return twoFactor.Enabled > 0
|
||||
}
|
||||
|
||||
// AuthInfo contains data used when authenticating a user. It should be
|
||||
// provided to Client.Auth(). Each AuthInfo can be used for only one login attempt.
|
||||
type AuthInfo struct {
|
||||
TwoFA *TwoFactorInfo `json:"2FA,omitempty"`
|
||||
|
||||
version int
|
||||
salt string
|
||||
modulus string
|
||||
srpSession string
|
||||
serverEphemeral string
|
||||
}
|
||||
|
||||
func (a *AuthInfo) HasTwoFactor() bool {
|
||||
if a.TwoFA == nil {
|
||||
return false
|
||||
}
|
||||
return a.TwoFA.hasTwoFactor()
|
||||
}
|
||||
|
||||
type AuthInfoRes struct {
|
||||
Res
|
||||
AuthInfo
|
||||
|
||||
Modulus string
|
||||
ServerEphemeral string
|
||||
Version int
|
||||
Salt string
|
||||
SRPSession string
|
||||
}
|
||||
|
||||
func (res *AuthInfoRes) getAuthInfo() *AuthInfo {
|
||||
info := &res.AuthInfo
|
||||
|
||||
// Some fields in AuthInfo are private, so we need to copy them from AuthRes
|
||||
// (private fields cannot be populated by json).
|
||||
info.version = res.Version
|
||||
info.salt = res.Salt
|
||||
info.modulus = res.Modulus
|
||||
info.srpSession = res.SRPSession
|
||||
info.serverEphemeral = res.ServerEphemeral
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
type AuthReq struct {
|
||||
Username string
|
||||
ClientProof string
|
||||
ClientEphemeral string
|
||||
SRPSession string
|
||||
}
|
||||
|
||||
// Auth contains data after a successful authentication. It should be provided to Client.Unlock().
|
||||
type Auth struct {
|
||||
accessToken string // Read from AuthRes.
|
||||
ExpiresIn int
|
||||
uid string // Read from AuthRes.
|
||||
RefreshToken string
|
||||
EventID string
|
||||
PasswordMode int
|
||||
TwoFA *TwoFactorInfo `json:"2FA,omitempty"`
|
||||
}
|
||||
|
||||
// UID returns the session UID from the Auth.
|
||||
// Only Auths generated from the /auth route will have the UID.
|
||||
// Auths generated from /auth/refresh are not required to.
|
||||
func (s *Auth) UID() string {
|
||||
return s.uid
|
||||
}
|
||||
|
||||
// GenToken generates a string token containing the session UID and refresh token.
|
||||
func (s *Auth) GenToken() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v:%v", s.UID(), s.RefreshToken)
|
||||
}
|
||||
|
||||
func (s *Auth) HasTwoFactor() bool {
|
||||
if s.TwoFA == nil {
|
||||
return false
|
||||
}
|
||||
return s.TwoFA.hasTwoFactor()
|
||||
}
|
||||
|
||||
func (s *Auth) HasMailboxPassword() bool {
|
||||
return s.PasswordMode == 2
|
||||
}
|
||||
|
||||
type AuthRes struct {
|
||||
Res
|
||||
Auth
|
||||
|
||||
AccessToken string
|
||||
TokenType string
|
||||
|
||||
// UID is the session UID. This is only present in an initial Auth (/auth), not in a refreshed Auth (/auth/refresh).
|
||||
UID string
|
||||
|
||||
ServerProof string
|
||||
}
|
||||
|
||||
func (res *AuthRes) getAuth() *Auth {
|
||||
auth := &res.Auth
|
||||
|
||||
// Some fields in Auth are private, so we need to copy them from AuthRes
|
||||
// (private fields cannot be populated by json).
|
||||
auth.accessToken = res.AccessToken
|
||||
auth.uid = res.UID
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
type Auth2FAReq struct {
|
||||
TwoFactorCode string
|
||||
|
||||
// Prepared for U2F:
|
||||
// U2F U2FRequest
|
||||
}
|
||||
|
||||
type Auth2FARes struct {
|
||||
Res
|
||||
}
|
||||
|
||||
type AuthRefreshReq struct {
|
||||
ResponseType string
|
||||
GrantType string
|
||||
RefreshToken string
|
||||
UID string
|
||||
RedirectURI string
|
||||
State string
|
||||
}
|
||||
|
||||
func (c *client) sendAuth(auth *Auth) {
|
||||
if auth != nil {
|
||||
c.log.WithField("auth", *auth).Debug("Client is sending auth to ClientManager")
|
||||
} else {
|
||||
c.log.Debug("Client is sending nil auth to ClientManager")
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
c.uid = auth.UID()
|
||||
c.accessToken = auth.accessToken
|
||||
}
|
||||
|
||||
c.cm.HandleAuth(ClientAuth{UserID: c.userID, Auth: auth})
|
||||
}
|
||||
|
||||
// AuthInfo gets authentication info for a user.
|
||||
func (c *client) AuthInfo(username string) (info *AuthInfo, err error) {
|
||||
infoReq := &AuthInfoReq{
|
||||
Username: username,
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/auth/info", infoReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var infoRes AuthInfoRes
|
||||
if err = c.DoJSON(req, &infoRes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
info, err = infoRes.getAuthInfo(), infoRes.Err()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func srpProofsFromInfo(info *AuthInfo, username, password string, fallbackVersion int) (proofs *srp.SrpProofs, err error) {
|
||||
version := info.version
|
||||
if version == 0 {
|
||||
version = fallbackVersion
|
||||
}
|
||||
|
||||
srpAuth, err := srp.NewSrpAuth(version, username, password, info.salt, info.modulus, info.serverEphemeral)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proofs, err = srpAuth.GenerateSrpProofs(2048)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) tryAuth(username, password string, info *AuthInfo, fallbackVersion int) (res *AuthRes, err error) {
|
||||
proofs, err := srpProofsFromInfo(info, username, password, fallbackVersion)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
authReq := &AuthReq{
|
||||
Username: username,
|
||||
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
|
||||
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
|
||||
SRPSession: info.srpSession,
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/auth", authReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var authRes AuthRes
|
||||
if err = c.DoJSON(req, &authRes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = authRes.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
serverProof, err := base64.StdEncoding.DecodeString(authRes.ServerProof)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(proofs.ExpectedServerProof, serverProof) != 1 {
|
||||
return nil, errors.New("pmapi: bad server proof")
|
||||
}
|
||||
|
||||
res, err = &authRes, authRes.Err()
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *client) tryFullAuth(username, password string, fallbackVersion int) (info *AuthInfo, authRes *AuthRes, err error) {
|
||||
info, err = c.AuthInfo(username)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
authRes, err = c.tryAuth(username, password, info, fallbackVersion)
|
||||
return
|
||||
}
|
||||
|
||||
// Auth will authenticate a user.
|
||||
func (c *client) Auth(username, password string, info *AuthInfo) (auth *Auth, err error) {
|
||||
if info == nil {
|
||||
if info, err = c.AuthInfo(username); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authRes, err := c.tryAuth(username, password, info, 2)
|
||||
if err != nil && info.version == 0 && srp.CleanUserName(username) != strings.ToLower(username) {
|
||||
info, authRes, err = c.tryFullAuth(username, password, 1)
|
||||
}
|
||||
if err != nil && info.version == 0 {
|
||||
_, authRes, err = c.tryFullAuth(username, password, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
auth = authRes.getAuth()
|
||||
c.sendAuth(auth)
|
||||
|
||||
return auth, err
|
||||
}
|
||||
|
||||
// Auth2FA will authenticate a user into full scope.
|
||||
// `Auth` struct contains method `HasTwoFactor` deciding whether this has to be done.
|
||||
func (c *client) Auth2FA(twoFactorCode string, auth *Auth) error {
|
||||
auth2FAReq := &Auth2FAReq{
|
||||
TwoFactorCode: twoFactorCode,
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/auth/2fa", auth2FAReq)
|
||||
if err != nil {
|
||||
func (c *client) Auth2FA(ctx context.Context, req Auth2FAReq) error {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Post("/auth/2fa")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var auth2FARes Auth2FARes
|
||||
if err := c.DoJSON(req, &auth2FARes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth2FARes.Err(); err != nil {
|
||||
switch auth2FARes.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return ErrBad2FACode
|
||||
case http.StatusUnprocessableEntity:
|
||||
return ErrBad2FACodeTryAgain
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthRefresh will refresh an expired access token.
|
||||
func (c *client) AuthRefresh(uidAndRefreshToken string) (auth *Auth, err error) {
|
||||
c.refreshLocker.Lock()
|
||||
defer c.refreshLocker.Unlock()
|
||||
|
||||
// If we don't yet have a saved access token, save this one in case the refresh fails!
|
||||
// That way we can try again later (see handleUnauthorizedStatus).
|
||||
c.cm.setTokenIfUnset(c.userID, uidAndRefreshToken)
|
||||
|
||||
split := strings.Split(uidAndRefreshToken, ":")
|
||||
if len(split) != 2 {
|
||||
err = ErrInvalidToken
|
||||
return
|
||||
func (c *client) AuthDelete(ctx context.Context) error {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.Delete("/auth")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refreshReq := &AuthRefreshReq{
|
||||
ResponseType: "token",
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: split[1],
|
||||
UID: split[0],
|
||||
RedirectURI: "https://protonmail.ch",
|
||||
State: "random_string",
|
||||
}
|
||||
c.uid, c.acc, c.ref, c.exp = "", "", "", time.Time{}
|
||||
|
||||
// UID must be set for `x-pm-uid` header field, see backend-communication#11
|
||||
c.uid = split[0]
|
||||
// FIXME(conman): should we perhaps signal via AuthHandler that the auth was deleted?
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/auth/refresh", refreshReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res AuthRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
auth = res.getAuth()
|
||||
|
||||
// Responses from /auth/refresh are not guaranteed to return the UID if it has not changed.
|
||||
// But we want to always return it.
|
||||
if auth.uid == "" {
|
||||
auth.uid = c.uid
|
||||
}
|
||||
|
||||
c.sendAuth(auth)
|
||||
|
||||
return auth, err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) AuthSalt() (string, error) {
|
||||
salts, err := c.GetKeySalts()
|
||||
func (c *client) AuthSalt(ctx context.Context) (string, error) {
|
||||
salts, err := c.GetKeySalts(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := c.CurrentUser(); err != nil {
|
||||
if _, err := c.CurrentUser(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@ -414,40 +54,37 @@ func (c *client) AuthSalt() (string, error) {
|
||||
return "", errors.New("no matching salt found")
|
||||
}
|
||||
|
||||
// Logout instructs the client manager to log this client out.
|
||||
func (c *client) Logout() {
|
||||
c.cm.LogoutClient(c.userID)
|
||||
func (c *client) AddAuthHandler(handler AuthHandler) {
|
||||
c.authHandlers = append(c.authHandlers, handler)
|
||||
}
|
||||
|
||||
// DeleteAuth deletes the API session.
|
||||
func (c *client) DeleteAuth() (err error) {
|
||||
req, err := c.NewRequest("DELETE", "/auth", nil)
|
||||
func (c *client) authRefresh(ctx context.Context) error {
|
||||
c.authLocker.Lock()
|
||||
defer c.authLocker.Unlock()
|
||||
|
||||
auth, err := c.req.authRefresh(ctx, c.uid, c.ref)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
c.acc = auth.AccessToken
|
||||
c.ref = auth.RefreshToken
|
||||
|
||||
for _, handler := range c.authHandlers {
|
||||
if err := handler(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomString(length int) string {
|
||||
noise := make([]byte, length)
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, noise); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is authorized to access the API.
|
||||
func (c *client) IsConnected() bool {
|
||||
return c.uid != "" && c.accessToken != ""
|
||||
}
|
||||
|
||||
// ClearData clears sensitive data from the client.
|
||||
func (c *client) ClearData() {
|
||||
c.uid = ""
|
||||
c.accessToken = ""
|
||||
c.addresses = nil
|
||||
c.user = nil
|
||||
c.clearKeys()
|
||||
return base64.StdEncoding.EncodeToString(noise)[:length]
|
||||
}
|
||||
|
||||
@ -1,351 +1,135 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
package pmapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
a "github.com/stretchr/testify/assert"
|
||||
r "github.com/stretchr/testify/require"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
)
|
||||
|
||||
var testIdentity = &crypto.Identity{
|
||||
Name: "UserID",
|
||||
Email: "",
|
||||
func TestAutomaticAuthRefresh(t *testing.T) {
|
||||
var wantAuth = &pmapi.Auth{
|
||||
UID: "testUID",
|
||||
AccessToken: "testAcc",
|
||||
RefreshToken: "testRef",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(wantAuth); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
var gotAuth *pmapi.Auth
|
||||
|
||||
// Create a new client.
|
||||
c := pmapi.New(pmapi.Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(-time.Second))
|
||||
|
||||
// Register an auth handler.
|
||||
c.AddAuthHandler(func(auth *pmapi.Auth) error { gotAuth = auth; return nil })
|
||||
|
||||
// Make a request with an access token that already expired one second ago.
|
||||
if _, err := c.GetAddresses(context.Background()); err != nil {
|
||||
t.Fatal("got unexpected error", err)
|
||||
}
|
||||
|
||||
// The auth callback should have been called.
|
||||
if *gotAuth != *wantAuth {
|
||||
t.Fatal("got unexpected auth", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
testUsername = "jason"
|
||||
testAPIPassword = "apple"
|
||||
func Test401AuthRefresh(t *testing.T) {
|
||||
var wantAuth = &pmapi.Auth{
|
||||
UID: "testUID",
|
||||
AccessToken: "testAcc",
|
||||
RefreshToken: "testRef",
|
||||
}
|
||||
|
||||
testUID = "729ad6012421d67ad26950dc898bebe3a6e3caa2" //nolint[gosec]
|
||||
testAccessToken = "de0423049b44243afeec7d9c1d99be7b46da1e8a" //nolint[gosec]
|
||||
testAccessTokenOld = "feb3159ac63fb05119bcf4480d939278aa746926" //nolint[gosec]
|
||||
testRefreshToken = "a49b98256745bb497bec20e9b55f5de16f01fb52" //nolint[gosec]
|
||||
testRefreshTokenNew = "b894b4c4f20003f12d486900d8b88c7d68e67235" //nolint[gosec]
|
||||
)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
var testAuthInfo = &AuthInfo{
|
||||
TwoFA: &TwoFactorInfo{TOTP: 1},
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
version: 4,
|
||||
salt: "yKlc5/CvObfoiw==",
|
||||
modulus: "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nW2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa\nGO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N\nkvNM7qIK\n=q6vu\n-----END PGP SIGNATURE-----\n",
|
||||
srpSession: "9b2946bbd9055f17c34940abdce0c3d3",
|
||||
serverEphemeral: "5tfigcLKoM0DPWYB+EqYE7QlqsiT63iOVlO5ZX0lTMEILSsrRdVCYrN8L3zkinsAjUZ/cx5wIS7N05k66uZb+ZE3lFOJS2s1BkqLvCrGxYL0e3n5YAnzHYlvCCJKXw/sK57ntfF1OOoblBXX6dw5LjeeDglEep2/DaE0TjD8WUpq4Ls2HlQGn9wrC7dFO2lJXsMhRffxKghiOsdvCLXDmwXginzn/LFezA8KrDsWOBSEGntwpg3s1xFj5h8BqtRHvC0igmoscqgw+3GCMTJ0NZAQ/L+5aJ/0ccL0WBK208ltCNl+/X6Sz0kpyvOP4RqFJhC1auVDJ9AjZQYSYZ1NEQ==",
|
||||
if err := json.NewEncoder(w).Encode(wantAuth); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
var call int
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
call++
|
||||
|
||||
if call == 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
var gotAuth *pmapi.Auth
|
||||
|
||||
// Create a new client.
|
||||
c := pmapi.New(pmapi.Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
|
||||
|
||||
// Register an auth handler.
|
||||
c.AddAuthHandler(func(auth *pmapi.Auth) error { gotAuth = auth; return nil })
|
||||
|
||||
// The first request will fail with 401, triggering a refresh and retry.
|
||||
if _, err := c.GetAddresses(context.Background()); err != nil {
|
||||
t.Fatal("got unexpected error", err)
|
||||
}
|
||||
|
||||
// The auth callback should have been called.
|
||||
if *gotAuth != *wantAuth {
|
||||
t.Fatal("got unexpected auth", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// testAuth has default values which are adjusted in each test.
|
||||
var testAuth = &Auth{
|
||||
EventID: "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==",
|
||||
ExpiresIn: 86400,
|
||||
RefreshToken: "feb3159ac63fb05119bcf4480d939278aa746926",
|
||||
func Test401RevokedAuth(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
accessToken: testAccessToken,
|
||||
uid: testUID,
|
||||
}
|
||||
|
||||
var testAuthRefreshReq = AuthRefreshReq{
|
||||
ResponseType: "token",
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: testRefreshToken,
|
||||
UID: testUID,
|
||||
RedirectURI: "https://protonmail.ch",
|
||||
State: "random_string",
|
||||
}
|
||||
|
||||
var testAuthReq = AuthReq{
|
||||
Username: testUsername,
|
||||
ClientProof: "axfvYdl9iXZjY6zQ+hBYmY7X3TDc/9JtSvrmyZXhDxjxkXB3Hro27t1KItmFIJloItY5sLZDs0eEEZJI34oFZD4ViSG0kfB7ZXcCZ9Jse+U5OFu4vdnPTGolnSofRMEs1NR6ePXzH7mQ10qoq43ity3ve2vmhQNuJNlHAPynKf2WqKOgxq7mmkBzEpXES4mIhwwgVbOygKcUSvguz5E5g13ATF0ZX2d9SJWAbZ262Tks+h99Cdk/dOfgLQhr0nO/r0cpwP84W2RWU2Q34LNkKuuQHkjmxelgBleGq54tCbhoCAYPP6vapgrQjNoVAC/dkjIIAoNL9bJSIynFM5znAA==",
|
||||
ClientEphemeral: "mK+eSMosfZO/Cs5s+vcbjpsN7F8UAObwlKKnCy/z9FpoMRM2PfTe5ywLBgffmLYaapPq7XOxaqaj08kcZLHcM1fIA2JQZZTKPnESN1qAQztJ3/YHMI0op6yBgzx9803OjIznjCD2B3XBSMOHIG4oG0UwocsIX32hiMnYlMMkt8NGrityPlnmEbxpRna3fu9LEZ+v0uo6PjKCrO7+9E3uaMi64HadXBfyx2raBFFwA+yh7FvE7U+hl3AJclEre4d8pmfhMdxXze1soJI8fMuqaa07rY0r0rF5mLLTuqTIGRFkU1qG9loq9+IMsSwgkt1P3ghW63JK7Y6LWdDy0d6cAg==",
|
||||
SRPSession: "9b2946bbd9055f17c34940abdce0c3d3",
|
||||
}
|
||||
|
||||
var testAuth2FAReq = Auth2FAReq{
|
||||
TwoFactorCode: "424242",
|
||||
}
|
||||
|
||||
func init() {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
srp.RandReader = rand.New(rand.NewSource(42))
|
||||
}
|
||||
|
||||
func TestClient_AuthInfo(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/auth/info"))
|
||||
|
||||
var infoReq AuthInfoReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&infoReq))
|
||||
Equals(t, infoReq.Username, testUsername)
|
||||
|
||||
return "/auth/info/post_response.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
info, err := c.AuthInfo(testCurrentUser.Name)
|
||||
Ok(t, err)
|
||||
Equals(t, testAuthInfo, info)
|
||||
}
|
||||
|
||||
// TestClient_Auth reflects changes from proton/backend-communcation#3.
|
||||
func TestClient_Auth(t *testing.T) {
|
||||
srp.RandReader = rand.New(rand.NewSource(42))
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
a.Nil(t, checkMethodAndPath(req, "POST", "/auth"))
|
||||
|
||||
var authReq AuthReq
|
||||
r.Nil(t, json.NewDecoder(req.Body).Decode(&authReq))
|
||||
r.Equal(t, testAuthReq, authReq)
|
||||
|
||||
return "/auth/post_response.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
auth, err := c.Auth(testUsername, testAPIPassword, testAuthInfo)
|
||||
r.Nil(t, err)
|
||||
|
||||
exp := &Auth{}
|
||||
*exp = *testAuth
|
||||
exp.accessToken = testAccessToken
|
||||
exp.RefreshToken = testRefreshToken
|
||||
a.Equal(t, exp, auth)
|
||||
}
|
||||
|
||||
func TestClient_Auth2FA(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
|
||||
|
||||
var info2FAReq Auth2FAReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
|
||||
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
|
||||
|
||||
return "/auth/2fa/post_response.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
|
||||
Ok(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Auth2FA_Fail(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
|
||||
|
||||
var info2FAReq Auth2FAReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
|
||||
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
|
||||
|
||||
return "/auth/2fa/post_401_bad_password.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
|
||||
Equals(t, ErrBad2FACode, err)
|
||||
}
|
||||
|
||||
func TestClient_Auth2FA_Retry(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/auth/2fa"))
|
||||
|
||||
var info2FAReq Auth2FAReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&info2FAReq))
|
||||
Equals(t, info2FAReq.TwoFactorCode, testAuth2FAReq.TwoFactorCode)
|
||||
|
||||
return "/auth/2fa/post_422_bad_password.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
|
||||
Equals(t, ErrBad2FACodeTryAgain, err)
|
||||
}
|
||||
|
||||
func TestClient_Unlock(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeGetUsers,
|
||||
routeGetAddresses,
|
||||
)
|
||||
defer finish()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
err := c.Unlock([]byte("wrong"))
|
||||
a.Error(t, err, "expected error, pasword is wrong")
|
||||
|
||||
err = c.Unlock([]byte(testMailboxPassword))
|
||||
a.Nil(t, err)
|
||||
a.Equal(t, testUID, c.uid)
|
||||
a.Equal(t, testAccessToken, c.accessToken)
|
||||
|
||||
// second try should not fail because there is an unlocked key already
|
||||
err = c.Unlock([]byte("wrong"))
|
||||
a.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Unlock_EncPrivKey(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeGetUsers,
|
||||
routeGetAddresses,
|
||||
)
|
||||
defer finish()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
err := c.Unlock([]byte(testMailboxPassword))
|
||||
Ok(t, err)
|
||||
Equals(t, testUID, c.uid)
|
||||
Equals(t, testAccessToken, c.accessToken)
|
||||
}
|
||||
|
||||
func routeAuthRefresh(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh"))
|
||||
Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID))
|
||||
|
||||
var refreshReq AuthRefreshReq
|
||||
Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq))
|
||||
Equals(tb, testAuthRefreshReq, refreshReq)
|
||||
|
||||
return "/auth/refresh/post_response.json"
|
||||
}
|
||||
|
||||
// TestClient_AuthRefresh reflects changes from proton/backend-communcation#11.
|
||||
func TestClient_AuthRefresh(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeAuthRefresh,
|
||||
)
|
||||
defer finish()
|
||||
c.uid = "" // Testing that we always send correct `x-pm-uid`.
|
||||
c.accessToken = "oldToken"
|
||||
|
||||
auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken)
|
||||
Ok(t, err)
|
||||
Equals(t, testUID, c.uid)
|
||||
|
||||
exp := &Auth{}
|
||||
*exp = *testAuth
|
||||
exp.uid = testUID // AuthRefresh will not return UID (only Auth returns the UID) we should set testUID to be able to generate token, see `GetToken`
|
||||
exp.accessToken = testAccessToken
|
||||
exp.EventID = ""
|
||||
exp.ExpiresIn = 360000
|
||||
exp.RefreshToken = testRefreshTokenNew
|
||||
Equals(t, exp, auth)
|
||||
}
|
||||
|
||||
func routeAuthRefreshHasUID(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(tb, checkMethodAndPath(r, "POST", "/auth/refresh"))
|
||||
Ok(tb, checkHeader(r.Header, "x-pm-uid", testUID))
|
||||
|
||||
var refreshReq AuthRefreshReq
|
||||
Ok(tb, json.NewDecoder(r.Body).Decode(&refreshReq))
|
||||
Equals(tb, testAuthRefreshReq, refreshReq)
|
||||
|
||||
return "/auth/refresh/post_resp_has_uid.json"
|
||||
}
|
||||
|
||||
// TestClient_AuthRefresh reflects changes from proton/backend-communcation#3.
|
||||
func TestClient_AuthRefresh_HasUID(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeAuthRefreshHasUID,
|
||||
)
|
||||
defer finish()
|
||||
c.uid = testUID
|
||||
c.accessToken = "oldToken"
|
||||
|
||||
auth, err := c.AuthRefresh(testUID + ":" + testRefreshToken)
|
||||
Ok(t, err)
|
||||
|
||||
exp := &Auth{}
|
||||
*exp = *testAuth
|
||||
exp.accessToken = testAccessToken
|
||||
exp.EventID = ""
|
||||
exp.ExpiresIn = 360000
|
||||
exp.RefreshToken = testRefreshTokenNew
|
||||
Equals(t, exp, auth)
|
||||
}
|
||||
|
||||
func TestClient_Logout(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/auth"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
return "auth/delete_response.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
c.Logout()
|
||||
|
||||
r.Eventually(t, func() bool {
|
||||
return c.IsConnected() == false && c.userKeyRing == nil && c.addresses == nil && c.user == nil
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestClient_DoUnauthorized(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/"))
|
||||
return httpResponse(http.StatusUnauthorized)
|
||||
},
|
||||
routeAuthRefresh,
|
||||
func(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
return httpResponse(http.StatusOK)
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessTokenOld
|
||||
c.cm.tokens[c.userID] = testUID + ":" + testRefreshToken
|
||||
|
||||
req, err := c.NewRequest("GET", "/", nil)
|
||||
Ok(t, err)
|
||||
|
||||
res, err := c.Do(req, true)
|
||||
Ok(t, err)
|
||||
|
||||
defer Ok(t, res.Body.Close())
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
c := pmapi.New(pmapi.Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
|
||||
|
||||
// The request will fail with 401, triggering a refresh.
|
||||
// The retry will also fail with 401, returning an error.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
if !errors.Is(err, pmapi.ErrUnauthorized) {
|
||||
t.Fatal("expected error to be ErrUnauthorized, instead got", err)
|
||||
}
|
||||
}
|
||||
|
||||
72
pkg/pmapi/auth_types.go
Normal file
72
pkg/pmapi/auth_types.go
Normal file
@ -0,0 +1,72 @@
|
||||
package pmapi
|
||||
|
||||
type AuthModulus struct {
|
||||
Modulus string
|
||||
ModulusID string
|
||||
}
|
||||
|
||||
type GetAuthInfoReq struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
type AuthInfo struct {
|
||||
Version int
|
||||
Modulus string
|
||||
ServerEphemeral string
|
||||
Salt string
|
||||
SRPSession string
|
||||
}
|
||||
|
||||
type TwoFAInfo struct {
|
||||
Enabled TwoFAStatus
|
||||
}
|
||||
|
||||
type TwoFAStatus int
|
||||
|
||||
const (
|
||||
TwoFADisabled TwoFAStatus = iota
|
||||
TOTPEnabled
|
||||
// TODO: Support UTF
|
||||
)
|
||||
|
||||
type PasswordMode int
|
||||
|
||||
const (
|
||||
OnePasswordMode PasswordMode = iota + 1
|
||||
TwoPasswordMode
|
||||
)
|
||||
|
||||
type AuthReq struct {
|
||||
Username string
|
||||
ClientProof string
|
||||
ClientEphemeral string
|
||||
SRPSession string
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
UserID string
|
||||
|
||||
UID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int64
|
||||
|
||||
Scope string
|
||||
ServerProof string
|
||||
|
||||
TwoFA TwoFAInfo `json:"2FA"`
|
||||
PasswordMode PasswordMode
|
||||
}
|
||||
|
||||
type Auth2FAReq struct {
|
||||
TwoFactorCode string
|
||||
}
|
||||
|
||||
type AuthRefreshReq struct {
|
||||
UID string
|
||||
RefreshToken string
|
||||
ResponseType string
|
||||
GrantType string
|
||||
RedirectURI string
|
||||
State string
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const protonStatusURL = "http://protonstatus.com/vpn_status"
|
||||
|
||||
// ErrNoInternetConnection indicates that both protonstatus and the API are unreachable.
|
||||
var ErrNoInternetConnection = errors.New("no internet connection")
|
||||
|
||||
// CheckConnection returns an error if there is no internet connection.
|
||||
// This should be moved to the ConnectionManager when it is implemented.
|
||||
func (cm *ClientManager) CheckConnection() error {
|
||||
// We use a normal dialer here which doesn't check tls fingerprints.
|
||||
client := &http.Client{Timeout: time.Second * 10}
|
||||
|
||||
// Do not cumulate timeouts, use goroutines.
|
||||
retStatus := make(chan error)
|
||||
retAPI := make(chan error)
|
||||
|
||||
// vpn_status endpoint is fast and returns only OK. We check the connection only.
|
||||
go checkConnection(client, protonStatusURL, retStatus)
|
||||
|
||||
// Check of API reachability also uses a fast endpoint.
|
||||
go checkConnection(client, cm.GetRootURL()+"/tests/ping", retAPI)
|
||||
|
||||
errStatus := <-retStatus
|
||||
errAPI := <-retAPI
|
||||
|
||||
switch {
|
||||
case errStatus == nil && errAPI == nil:
|
||||
return nil
|
||||
|
||||
case errStatus == nil && errAPI != nil:
|
||||
cm.log.Error("ProtonStatus is reachable but API is not")
|
||||
return ErrAPINotReachable
|
||||
|
||||
case errStatus != nil && errAPI == nil:
|
||||
cm.log.Warn("API is reachable but protonstatus is not")
|
||||
return nil
|
||||
|
||||
case errStatus != nil && errAPI != nil:
|
||||
cm.log.Error("Both ProtonStatus and API are unreachable")
|
||||
return ErrNoInternetConnection
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckConnection returns an error if there is no internet connection.
|
||||
func CheckConnection() error {
|
||||
client := &http.Client{Timeout: time.Second * 10}
|
||||
retStatus := make(chan error)
|
||||
go checkConnection(client, protonStatusURL, retStatus)
|
||||
return <-retStatus
|
||||
}
|
||||
|
||||
func checkConnection(client *http.Client, url string, errorChannel chan error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
errorChannel <- err
|
||||
return
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
errorChannel <- fmt.Errorf("HTTP status code %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
errorChannel <- nil
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/dialer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testServerPort = "18000"
|
||||
const testRequestTimeout = 10 * time.Second
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
go startServer()
|
||||
time.Sleep(100 * time.Millisecond) // We need to wait till server is fully running.
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
http.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
http.HandleFunc("/timeout", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(testRequestTimeout + time.Second) // Add extra second to be sure it will timeout.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
http.HandleFunc("/serverError", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
})
|
||||
panic(http.ListenAndServe(":"+testServerPort, nil))
|
||||
}
|
||||
|
||||
func TestCheckConnection(t *testing.T) {
|
||||
checkCheckConnection(t, "ok", "")
|
||||
}
|
||||
|
||||
func TestCheckConnectionTimeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
checkCheckConnection(t, "timeout", "Client.Timeout exceeded while awaiting headers")
|
||||
}
|
||||
|
||||
func TestCheckConnectionServerError(t *testing.T) {
|
||||
checkCheckConnection(t, "serverError", "HTTP status code 500")
|
||||
}
|
||||
|
||||
func checkCheckConnection(t *testing.T, path string, expectedErrMessage string) {
|
||||
client := dialer.DialTimeoutClient()
|
||||
client.Timeout = testRequestTimeout
|
||||
|
||||
ch := make(chan error)
|
||||
|
||||
go checkConnection(client, "http://localhost:"+testServerPort+"/"+path, ch)
|
||||
|
||||
timeout := time.After(testRequestTimeout + time.Second)
|
||||
select {
|
||||
case err := <-ch:
|
||||
if expectedErrMessage == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err, expectedErrMessage)
|
||||
}
|
||||
case <-timeout:
|
||||
t.Error("checkConnection timeout failed")
|
||||
}
|
||||
}
|
||||
@ -18,97 +18,23 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/jaytaylor/html2text"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Version of the API.
|
||||
const Version = 3
|
||||
|
||||
// API return codes.
|
||||
const (
|
||||
ForceUpgradeBadAppVersion = 5003
|
||||
APIOffline = 7001
|
||||
ImportMessageTooLong = 36022
|
||||
BansRequests = 85131
|
||||
)
|
||||
|
||||
// The output errors.
|
||||
var (
|
||||
ErrInvalidToken = errors.New("refresh token invalid")
|
||||
ErrAPINotReachable = errors.New("cannot reach the server")
|
||||
ErrUpgradeApplication = errors.New("application upgrade required")
|
||||
ErrConnectionSlow = errors.New("request canceled because connection speed was too slow")
|
||||
)
|
||||
|
||||
type ErrUnprocessableEntity struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (err *ErrUnprocessableEntity) Error() string {
|
||||
return err.error.Error()
|
||||
}
|
||||
|
||||
type ErrUnauthorized struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (err *ErrUnauthorized) Error() string {
|
||||
return fmt.Sprintf("unauthorized access: %+v", err.error.Error())
|
||||
}
|
||||
|
||||
// ClientConfig contains Client configuration.
|
||||
type ClientConfig struct {
|
||||
// The client application name and version.
|
||||
AppVersion string
|
||||
|
||||
// The client ID.
|
||||
ClientID string
|
||||
|
||||
// Timeout is the timeout of the full request. It is passed to http.Client.
|
||||
// If it is left unset, it means no timeout is applied.
|
||||
Timeout time.Duration
|
||||
|
||||
// FirstReadTimeout specifies the timeout from getting response to the first read of body response.
|
||||
// This timeout is applied only when MinBytesPerSecond is used.
|
||||
// Default is 5 minutes.
|
||||
FirstReadTimeout time.Duration
|
||||
|
||||
// MinBytesPerSecond specifies minimum Bytes per second or the request will be canceled.
|
||||
// Zero means no limitation.
|
||||
MinBytesPerSecond int64
|
||||
|
||||
ConnectionOnHandler func()
|
||||
ConnectionOffHandler func()
|
||||
UpgradeApplicationHandler func()
|
||||
}
|
||||
|
||||
// client is a client of the protonmail API. It implements the Client interface.
|
||||
type client struct {
|
||||
cm *ClientManager
|
||||
hc *http.Client
|
||||
req requester
|
||||
|
||||
uid string
|
||||
accessToken string
|
||||
userID string
|
||||
requestLocker sync.Locker
|
||||
refreshLocker sync.Locker
|
||||
uid, acc, ref string
|
||||
authHandlers []AuthHandler
|
||||
authLocker sync.RWMutex
|
||||
|
||||
user *User
|
||||
addresses AddressList
|
||||
@ -116,404 +42,79 @@ type client struct {
|
||||
addrKeyRing map[string]*crypto.KeyRing
|
||||
keyRingLock sync.Locker
|
||||
|
||||
log *logrus.Entry
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// newClient creates a new API client.
|
||||
func newClient(cm *ClientManager, userID string) *client {
|
||||
func newClient(req requester, uid string) *client {
|
||||
return &client{
|
||||
cm: cm,
|
||||
hc: getHTTPClient(cm.config, cm.roundTripper, cm.cookieJar),
|
||||
userID: userID,
|
||||
requestLocker: &sync.Mutex{},
|
||||
refreshLocker: &sync.Mutex{},
|
||||
keyRingLock: &sync.Mutex{},
|
||||
addrKeyRing: make(map[string]*crypto.KeyRing),
|
||||
log: logrus.WithField("pkg", "pmapi").WithField("userID", userID),
|
||||
req: req,
|
||||
uid: uid,
|
||||
addrKeyRing: make(map[string]*crypto.KeyRing),
|
||||
keyRingLock: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// getHTTPClient returns a http client configured by the given client config and using the given transport.
|
||||
func getHTTPClient(cfg *ClientConfig, rt http.RoundTripper, jar http.CookieJar) (hc *http.Client) {
|
||||
return &http.Client{
|
||||
Transport: rt,
|
||||
Jar: jar,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
func (c *client) withAuth(acc, ref string, exp time.Time) *client {
|
||||
c.acc = acc
|
||||
c.ref = ref
|
||||
c.exp = exp
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *client) IsUnlocked() bool {
|
||||
return c.userKeyRing != nil
|
||||
}
|
||||
|
||||
// Unlock unlocks all the user and address keys using the given passphrase, creating user and address keyrings.
|
||||
// If the keyrings are already present, they are not recreated.
|
||||
func (c *client) Unlock(passphrase []byte) (err error) {
|
||||
c.keyRingLock.Lock()
|
||||
defer c.keyRingLock.Unlock()
|
||||
|
||||
return c.unlock(passphrase)
|
||||
}
|
||||
|
||||
// unlock unlocks the user's keys but without locking the keyring lock first.
|
||||
// Should only be used internally by methods that first lock the lock.
|
||||
func (c *client) unlock(passphrase []byte) (err error) {
|
||||
if _, err = c.CurrentUser(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.userKeyRing == nil {
|
||||
if err = c.unlockUser(passphrase); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock user")
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range c.addresses {
|
||||
if c.addrKeyRing[address.ID] == nil {
|
||||
if err = c.unlockAddress(passphrase, address); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) ReloadKeys(passphrase []byte) (err error) {
|
||||
c.keyRingLock.Lock()
|
||||
defer c.keyRingLock.Unlock()
|
||||
|
||||
c.clearKeys()
|
||||
|
||||
return c.unlock(passphrase)
|
||||
}
|
||||
|
||||
func (c *client) clearKeys() {
|
||||
if c.userKeyRing != nil {
|
||||
c.userKeyRing.ClearPrivateParams()
|
||||
c.userKeyRing = nil
|
||||
}
|
||||
|
||||
for id, kr := range c.addrKeyRing {
|
||||
if kr != nil {
|
||||
kr.ClearPrivateParams()
|
||||
}
|
||||
delete(c.addrKeyRing, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) CloseConnections() {
|
||||
c.hc.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// Do makes an API request. It does not check for HTTP status code errors.
|
||||
func (c *client) Do(req *http.Request, retryUnauthorized bool) (res *http.Response, err error) {
|
||||
// Copy the request body in case we need to retry it.
|
||||
var bodyBuffer []byte
|
||||
if req.Body != nil {
|
||||
defer req.Body.Close() //nolint[errcheck]
|
||||
bodyBuffer, err = ioutil.ReadAll(req.Body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(bodyBuffer)
|
||||
req.Body = ioutil.NopCloser(r)
|
||||
}
|
||||
|
||||
return c.doBuffered(req, bodyBuffer, retryUnauthorized)
|
||||
}
|
||||
|
||||
// If needed it retries using req and buffered body.
|
||||
func (c *client) doBuffered(req *http.Request, bodyBuffer []byte, retryUnauthorized bool) (res *http.Response, err error) { // nolint[funlen]
|
||||
isAuthReq := strings.Contains(req.URL.Path, "/auth")
|
||||
|
||||
req.Header.Set("User-Agent", c.cm.userAgent.String())
|
||||
req.Header.Set("x-pm-appversion", c.cm.config.AppVersion)
|
||||
func (c *client) r(ctx context.Context) (*resty.Request, error) {
|
||||
r := c.req.r(ctx)
|
||||
|
||||
if c.uid != "" {
|
||||
req.Header.Set("x-pm-uid", c.uid)
|
||||
r.SetHeader("x-pm-uid", c.uid)
|
||||
}
|
||||
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
c.log.Debugln("Requesting ", req.Method, req.URL.RequestURI())
|
||||
if logrus.GetLevel() == logrus.TraceLevel {
|
||||
head := ""
|
||||
for i, v := range req.Header {
|
||||
head += i + ": "
|
||||
head += strings.Join(v, "")
|
||||
head += "\n"
|
||||
}
|
||||
c.log.Tracef("REQHEAD \n%s", head)
|
||||
c.log.Tracef("REQBODY '%s'", printBytes(bodyBuffer))
|
||||
}
|
||||
|
||||
hasBody := len(bodyBuffer) > 0
|
||||
if res, err = c.hc.Do(req); err != nil {
|
||||
if res == nil {
|
||||
c.log.WithError(err).Error("Cannot get response")
|
||||
err = ErrAPINotReachable
|
||||
c.cm.noConnection()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Cookies are returned only after request was sent.
|
||||
c.log.Tracef("REQCOOKIES '%v'", req.Cookies())
|
||||
|
||||
resDate := res.Header.Get("Date")
|
||||
if resDate != "" {
|
||||
if serverTime, err := http.ParseTime(resDate); err == nil {
|
||||
crypto.UpdateTime(serverTime.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
if hasBody {
|
||||
r := bytes.NewReader(bodyBuffer)
|
||||
req.Body = ioutil.NopCloser(r)
|
||||
}
|
||||
|
||||
if !isAuthReq {
|
||||
_, _ = io.Copy(ioutil.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
return c.handleStatusUnauthorized(req, bodyBuffer, res, retryUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry induced by HTTP status code>
|
||||
retryAfter := 10
|
||||
doRetry := res.StatusCode == http.StatusTooManyRequests
|
||||
if doRetry {
|
||||
if headerAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && headerAfter > 0 {
|
||||
retryAfter = headerAfter
|
||||
}
|
||||
// To avoid spikes when all clients retry at the same time, we add some random wait.
|
||||
retryAfter += rand.Intn(10) //nolint[gosec] It is OK to use weak random number generator here
|
||||
|
||||
if hasBody {
|
||||
r := bytes.NewReader(bodyBuffer)
|
||||
req.Body = ioutil.NopCloser(r)
|
||||
}
|
||||
|
||||
c.log.Warningf("Retrying %s after %ds induced by http code %d", req.URL.Path, retryAfter, res.StatusCode)
|
||||
time.Sleep(time.Duration(retryAfter) * time.Second)
|
||||
_, _ = io.Copy(ioutil.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
return c.doBuffered(req, bodyBuffer, false)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// DoJSON performs the request and unmarshals the response as JSON into data.
|
||||
// If the API returns a non-2xx HTTP status code, the error returned will contain status
|
||||
// and response as plaintext. API errors must be checked by the caller.
|
||||
// It is performed buffered, in case we need to retry.
|
||||
func (c *client) DoJSON(req *http.Request, data interface{}) error {
|
||||
// Copy the request body in case we need to retry it
|
||||
var reqBodyBuffer []byte
|
||||
|
||||
if req.Body != nil {
|
||||
defer req.Body.Close() //nolint[errcheck]
|
||||
var err error
|
||||
if reqBodyBuffer, err = ioutil.ReadAll(req.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer))
|
||||
}
|
||||
|
||||
return c.doJSONBuffered(req, reqBodyBuffer, data)
|
||||
}
|
||||
|
||||
// doJSONBuffered performs a buffered json request (see DoJSON for more information).
|
||||
func (c *client) doJSONBuffered(req *http.Request, reqBodyBuffer []byte, data interface{}) error { // nolint[funlen]
|
||||
req.Header.Set("Accept", "application/vnd.protonmail.v1+json")
|
||||
|
||||
var cancelRequest context.CancelFunc
|
||||
if c.cm.config.MinBytesPerSecond > 0 {
|
||||
var ctx context.Context
|
||||
ctx, cancelRequest = context.WithCancel(req.Context())
|
||||
defer func() {
|
||||
cancelRequest()
|
||||
}()
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
res, err := c.doBuffered(req, reqBodyBuffer, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close() //nolint[errcheck]
|
||||
|
||||
var resBody []byte
|
||||
if c.cm.config.MinBytesPerSecond == 0 {
|
||||
resBody, err = ioutil.ReadAll(res.Body)
|
||||
} else {
|
||||
resBody, err = c.readAllMinSpeed(res.Body, cancelRequest)
|
||||
if err == context.Canceled {
|
||||
err = ErrConnectionSlow
|
||||
}
|
||||
}
|
||||
|
||||
// The server response may contain data which we want to have in memory
|
||||
// for as little time as possible (such as keys). Go is garbage collected,
|
||||
// so we are not in charge of when the memory will actually be cleared.
|
||||
// We can at least try to rewrite the original data to mitigate this problem.
|
||||
defer func() {
|
||||
for i := 0; i < len(resBody); i++ {
|
||||
resBody[i] = byte(65)
|
||||
}
|
||||
}()
|
||||
|
||||
if logrus.GetLevel() == logrus.TraceLevel {
|
||||
head := ""
|
||||
for i, v := range res.Header {
|
||||
head += i + ": "
|
||||
head += strings.Join(v, "")
|
||||
head += "\n"
|
||||
}
|
||||
c.log.Tracef("RESHEAD \n%s", head)
|
||||
c.log.Tracef("RESBODY '%s'", resBody)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Retry induced by API code.
|
||||
errCode := &Res{}
|
||||
if err := json.Unmarshal(resBody, errCode); err == nil {
|
||||
if errCode.Code == BansRequests {
|
||||
retryAfter := 3
|
||||
c.log.Warningf("Retrying %s after %ds induced by API code %d", req.URL.Path, retryAfter, errCode.Code)
|
||||
time.Sleep(time.Duration(retryAfter) * time.Second)
|
||||
if len(reqBodyBuffer) > 0 {
|
||||
req.Body = ioutil.NopCloser(bytes.NewReader(reqBodyBuffer))
|
||||
}
|
||||
return c.doJSONBuffered(req, reqBodyBuffer, data)
|
||||
}
|
||||
if errCode.Err() == ErrAPINotReachable {
|
||||
c.cm.noConnection()
|
||||
}
|
||||
if errCode.Err() == ErrUpgradeApplication {
|
||||
c.cm.config.UpgradeApplicationHandler()
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(resBody, data); err != nil {
|
||||
// Check to see if this is due to a non 2xx HTTP status code.
|
||||
if res.StatusCode != http.StatusOK {
|
||||
r := bytes.NewReader(bytes.ReplaceAll(resBody, []byte("\n"), []byte("\\n")))
|
||||
plaintext, err := html2text.FromReader(r)
|
||||
if err == nil {
|
||||
return fmt.Errorf("Error: \n\n" + res.Status + "\n\n" + plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
if errJS, ok := err.(*json.SyntaxError); ok {
|
||||
return fmt.Errorf("invalid json %v (offset:%d) ", errJS.Error(), errJS.Offset)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unmarshal fail: %v ", err)
|
||||
}
|
||||
|
||||
// Set StatusCode in case data struct supports that field.
|
||||
// It's safe to set StatusCode, server returns Code. StatusCode should be preferred over Code.
|
||||
dataValue := reflect.ValueOf(data).Elem()
|
||||
statusCodeField := dataValue.FieldByName("StatusCode")
|
||||
if statusCodeField.IsValid() && statusCodeField.CanSet() && statusCodeField.Kind() == reflect.Int {
|
||||
statusCodeField.SetInt(int64(res.StatusCode))
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
c.log.Warnf("request %s %s NOT OK: %s", req.Method, req.URL.Path, res.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) readAllMinSpeed(data io.Reader, cancelRequest context.CancelFunc) ([]byte, error) {
|
||||
firstReadTimeout := c.cm.config.FirstReadTimeout
|
||||
if firstReadTimeout == 0 {
|
||||
firstReadTimeout = 5 * time.Minute
|
||||
}
|
||||
timer := time.AfterFunc(firstReadTimeout, func() {
|
||||
cancelRequest()
|
||||
})
|
||||
|
||||
// speedCheckSeconds controls how often we check the transfer speed.
|
||||
// Note that connection can be unstable, on average very fast, but can be
|
||||
// idle for few seconds; or that API can take its time before sending
|
||||
// another data, e.g., API can send some data and take some time before
|
||||
// processing and sending the rest of the response.
|
||||
const speedCheckSeconds = 30
|
||||
|
||||
var buffer bytes.Buffer
|
||||
for {
|
||||
_, err := io.CopyN(&buffer, data, c.cm.config.MinBytesPerSecond*speedCheckSeconds)
|
||||
timer.Stop()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
if time.Now().After(c.exp) {
|
||||
if err := c.authRefresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timer.Reset(speedCheckSeconds * time.Second)
|
||||
}
|
||||
|
||||
return ioutil.ReadAll(&buffer)
|
||||
c.authLocker.RLock()
|
||||
defer c.authLocker.RUnlock()
|
||||
|
||||
if c.acc != "" {
|
||||
r.SetAuthToken(c.acc)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *client) refreshAccessToken() (err error) {
|
||||
c.log.Debug("Refreshing token")
|
||||
|
||||
refreshToken := c.cm.GetToken(c.userID)
|
||||
|
||||
if refreshToken == "" {
|
||||
c.sendAuth(nil)
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if _, err := c.AuthRefresh(refreshToken); err != nil {
|
||||
if err != ErrAPINotReachable {
|
||||
c.sendAuth(nil)
|
||||
}
|
||||
return errors.Wrap(err, "failed to refresh auth")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) handleStatusUnauthorized(req *http.Request, reqBodyBuffer []byte, res *http.Response, retry bool) (retryRes *http.Response, err error) {
|
||||
c.log.Info("Handling unauthorized status")
|
||||
|
||||
// If this is not a retry, then it is the first time handling status unauthorized,
|
||||
// so try again without refreshing the access token.
|
||||
if !retry {
|
||||
c.log.Debug("Handling unauthorized status by retrying")
|
||||
c.requestLocker.Lock()
|
||||
defer c.requestLocker.Unlock()
|
||||
|
||||
_, _ = io.Copy(ioutil.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
return c.doBuffered(req, reqBodyBuffer, true)
|
||||
}
|
||||
|
||||
// This is already a retry, so we will try to refresh the access token before trying again.
|
||||
if err = c.refreshAccessToken(); err != nil {
|
||||
c.log.WithError(err).Warn("Cannot refresh token")
|
||||
err = &ErrUnauthorized{err}
|
||||
return
|
||||
}
|
||||
_, err = io.Copy(ioutil.Discard, res.Body)
|
||||
func (c *client) do(ctx context.Context, fn func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) {
|
||||
r, err := c.r(ctx)
|
||||
if err != nil {
|
||||
c.log.WithError(err).Warn("Failed to read out response body")
|
||||
return nil, err
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
return c.doBuffered(req, reqBodyBuffer, true)
|
||||
|
||||
res, err := wrapRestyError(fn(r))
|
||||
if err != nil {
|
||||
if res.StatusCode() != http.StatusUnauthorized {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.authRefresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wrapRestyError(fn(r))
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func wrapRestyError(res *resty.Response, err error) (*resty.Response, error) {
|
||||
if err, ok := err.(*resty.ResponseError); ok {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.RawResponse != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
return res, errors.Wrap(ErrNoConnection, err.Error())
|
||||
}
|
||||
|
||||
70
pkg/pmapi/client_keys.go
Normal file
70
pkg/pmapi/client_keys.go
Normal file
@ -0,0 +1,70 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Unlock unlocks all the user and address keys using the given passphrase, creating user and address keyrings.
|
||||
// If the keyrings are already present, they are not recreated.
|
||||
func (c *client) Unlock(ctx context.Context, passphrase []byte) (err error) {
|
||||
c.keyRingLock.Lock()
|
||||
defer c.keyRingLock.Unlock()
|
||||
|
||||
// FIXME(conman): Should this be done as part of NewClient somehow?
|
||||
|
||||
return c.unlock(ctx, passphrase)
|
||||
}
|
||||
|
||||
// unlock unlocks the user's keys but without locking the keyring lock first.
|
||||
// Should only be used internally by methods that first lock the lock.
|
||||
func (c *client) unlock(ctx context.Context, passphrase []byte) (err error) {
|
||||
if _, err = c.CurrentUser(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.userKeyRing == nil {
|
||||
if err = c.unlockUser(passphrase); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock user")
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range c.addresses {
|
||||
if c.addrKeyRing[address.ID] == nil {
|
||||
if err = c.unlockAddress(passphrase, address); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) ReloadKeys(ctx context.Context, passphrase []byte) (err error) {
|
||||
c.keyRingLock.Lock()
|
||||
defer c.keyRingLock.Unlock()
|
||||
|
||||
c.clearKeys()
|
||||
|
||||
return c.unlock(ctx, passphrase)
|
||||
}
|
||||
|
||||
func (c *client) clearKeys() {
|
||||
if c.userKeyRing != nil {
|
||||
c.userKeyRing.ClearPrivateParams()
|
||||
c.userKeyRing = nil
|
||||
}
|
||||
|
||||
for id, kr := range c.addrKeyRing {
|
||||
if kr != nil {
|
||||
kr.ClearPrivateParams()
|
||||
}
|
||||
delete(c.addrKeyRing, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) IsUnlocked() bool {
|
||||
// FIXME(conman): Better way to check? we don't currently check address keys.
|
||||
return c.userKeyRing != nil
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testClientConfig = &ClientConfig{
|
||||
AppVersion: "GoPMAPI_1.0.14",
|
||||
ClientID: "demoapp",
|
||||
FirstReadTimeout: 500 * time.Millisecond,
|
||||
MinBytesPerSecond: 256,
|
||||
}
|
||||
|
||||
func newTestClient(cm *ClientManager) *client {
|
||||
return cm.GetClient("tester").(*client)
|
||||
}
|
||||
|
||||
func TestClient_Do(t *testing.T) {
|
||||
const testResBody = "Hello World!"
|
||||
|
||||
var receivedReq *http.Request
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedReq = r
|
||||
fmt.Fprint(w, testResBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
req, err := c.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while creating request, got:", err)
|
||||
}
|
||||
|
||||
res, err := c.Do(req, true)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while executing request, got:", err)
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while reading response, got:", err)
|
||||
}
|
||||
require.Nil(t, res.Body.Close())
|
||||
|
||||
if string(b) != testResBody {
|
||||
t.Fatalf("Invalid response body: expected %v, got %v", testResBody, string(b))
|
||||
}
|
||||
|
||||
h := receivedReq.Header
|
||||
if h.Get("x-pm-appversion") != testClientConfig.AppVersion {
|
||||
t.Fatalf("Invalid app version header: expected %v, got %v", testClientConfig.AppVersion, h.Get("x-pm-appversion"))
|
||||
}
|
||||
if h.Get("x-pm-uid") != "" {
|
||||
t.Fatalf("Expected no uid header when not authenticated, got %v", h.Get("x-pm-uid"))
|
||||
}
|
||||
if h.Get("Authorization") != "" {
|
||||
t.Fatalf("Expected no authentication header when not authenticated, got %v", h.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DoRetryAfter(t *testing.T) {
|
||||
testStart := time.Now()
|
||||
secondAttemptTime := time.Now()
|
||||
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
w.Header().Set("content-type", "application/json;charset=utf-8")
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return ""
|
||||
},
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
w.Header().Set("content-type", "application/json;charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
secondAttemptTime = time.Now()
|
||||
return "/HTTP_200.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
require.Nil(t, c.SendSimpleMetric("some_category", "some_action", "some_label"))
|
||||
waitedTime := secondAttemptTime.Sub(testStart)
|
||||
isInRange := 1*time.Second < waitedTime && waitedTime <= 11*time.Second
|
||||
require.True(t, isInRange, "Waited time: %v", waitedTime)
|
||||
}
|
||||
|
||||
type slowTransport struct {
|
||||
transport http.RoundTripper
|
||||
firstBodySleep time.Duration
|
||||
}
|
||||
|
||||
func (t *slowTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
resp.Body = &slowReadCloser{
|
||||
req: req,
|
||||
readCloser: resp.Body,
|
||||
firstBodySleep: t.firstBodySleep,
|
||||
}
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
type slowReadCloser struct {
|
||||
req *http.Request
|
||||
readCloser io.ReadCloser
|
||||
firstBodySleep time.Duration
|
||||
}
|
||||
|
||||
func (r *slowReadCloser) Read(p []byte) (n int, err error) {
|
||||
// Normally timeout is processed by Read function.
|
||||
// It's hard to test slow connection; we need to manually
|
||||
// check when context is Done, because otherwise timeout
|
||||
// happens only during failed Read which will not happen
|
||||
// in this artificial environment.
|
||||
select {
|
||||
case <-r.req.Context().Done():
|
||||
return 0, context.Canceled
|
||||
case <-time.After(r.firstBodySleep):
|
||||
}
|
||||
return r.readCloser.Read(p)
|
||||
}
|
||||
|
||||
func (r *slowReadCloser) Close() error {
|
||||
return r.readCloser.Close()
|
||||
}
|
||||
|
||||
func TestClient_FirstReadTimeout(t *testing.T) {
|
||||
requestTimeout := testClientConfig.FirstReadTimeout + 1*time.Second
|
||||
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
return "/HTTP_200.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.hc.Transport = &slowTransport{
|
||||
transport: c.hc.Transport,
|
||||
firstBodySleep: requestTimeout,
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
|
||||
require.Error(t, err, "cannot reach the server")
|
||||
require.True(t, time.Since(started) < requestTimeout, "Actual waited time: %v", time.Since(started))
|
||||
}
|
||||
|
||||
func TestClient_MinSpeedTimeout(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeSlow(31*time.Second), // 1 second longer than the minimum transfer speed poll time.
|
||||
)
|
||||
defer finish()
|
||||
|
||||
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
|
||||
require.Error(t, err, "cannot reach the server")
|
||||
}
|
||||
|
||||
func TestClient_MinSpeedNoTimeout(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeSlow(500*time.Millisecond),
|
||||
)
|
||||
defer finish()
|
||||
|
||||
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func routeSlow(delay time.Duration) func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
return func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
w.Header().Set("content-type", "application/json;charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, _ = w.Write([]byte("{\"code\":1000,\"key\":\""))
|
||||
for chunk := 1; chunk <= 10; chunk++ {
|
||||
// We need to write enough bytes which enforce flushing data
|
||||
// because writer used by httptest does not implement Flusher.
|
||||
for i := 1; i <= 10000; i++ {
|
||||
_, _ = w.Write([]byte("a"))
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
_, _ = w.Write([]byte("\"}"))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@ -18,69 +18,66 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Client defines the interface of a PMAPI client.
|
||||
type Client interface {
|
||||
Auth(username, password string, info *AuthInfo) (*Auth, error)
|
||||
AuthInfo(username string) (*AuthInfo, error)
|
||||
AuthRefresh(token string) (*Auth, error)
|
||||
Auth2FA(twoFactorCode string, auth *Auth) error
|
||||
AuthSalt() (salt string, err error)
|
||||
Logout()
|
||||
DeleteAuth() error
|
||||
IsConnected() bool
|
||||
CloseConnections()
|
||||
ClearData()
|
||||
Auth2FA(context.Context, Auth2FAReq) error
|
||||
AuthSalt(ctx context.Context) (string, error)
|
||||
AuthDelete(context.Context) error
|
||||
AddAuthHandler(AuthHandler)
|
||||
|
||||
CurrentUser() (*User, error)
|
||||
UpdateUser() (*User, error)
|
||||
Unlock(passphrase []byte) (err error)
|
||||
ReloadKeys(passphrase []byte) (err error)
|
||||
CurrentUser(ctx context.Context) (*User, error)
|
||||
UpdateUser(ctx context.Context) (*User, error)
|
||||
Unlock(ctx context.Context, passphrase []byte) (err error)
|
||||
ReloadKeys(ctx context.Context, passphrase []byte) (err error)
|
||||
IsUnlocked() bool
|
||||
|
||||
GetAddresses() (addresses AddressList, err error)
|
||||
Addresses() AddressList
|
||||
ReorderAddresses(addressIDs []string) error
|
||||
GetAddresses(context.Context) (addresses AddressList, err error)
|
||||
ReorderAddresses(ctx context.Context, addressIDs []string) error
|
||||
|
||||
GetEvent(eventID string) (*Event, error)
|
||||
GetEvent(ctx context.Context, eventID string) (*Event, error)
|
||||
|
||||
SendMessage(string, *SendMessageReq) (sent, parent *Message, err error)
|
||||
CreateDraft(m *Message, parent string, action int) (created *Message, err error)
|
||||
Import([]*ImportMsgReq) ([]*ImportMsgRes, error)
|
||||
SendMessage(context.Context, string, *SendMessageReq) (sent, parent *Message, err error)
|
||||
CreateDraft(ctx context.Context, m *Message, parent string, action int) (created *Message, err error)
|
||||
Import(context.Context, ImportMsgReqs) ([]*ImportMsgRes, error)
|
||||
|
||||
CountMessages(addressID string) ([]*MessagesCount, error)
|
||||
ListMessages(filter *MessagesFilter) ([]*Message, int, error)
|
||||
GetMessage(apiID string) (*Message, error)
|
||||
DeleteMessages(apiIDs []string) error
|
||||
LabelMessages(apiIDs []string, labelID string) error
|
||||
UnlabelMessages(apiIDs []string, labelID string) error
|
||||
MarkMessagesRead(apiIDs []string) error
|
||||
MarkMessagesUnread(apiIDs []string) error
|
||||
CountMessages(ctx context.Context, addressID string) ([]*MessagesCount, error)
|
||||
ListMessages(ctx context.Context, filter *MessagesFilter) ([]*Message, int, error)
|
||||
GetMessage(ctx context.Context, apiID string) (*Message, error)
|
||||
DeleteMessages(ctx context.Context, apiIDs []string) error
|
||||
LabelMessages(ctx context.Context, apiIDs []string, labelID string) error
|
||||
UnlabelMessages(ctx context.Context, apiIDs []string, labelID string) error
|
||||
MarkMessagesRead(ctx context.Context, apiIDs []string) error
|
||||
MarkMessagesUnread(ctx context.Context, apiIDs []string) error
|
||||
|
||||
ListLabels() ([]*Label, error)
|
||||
CreateLabel(label *Label) (*Label, error)
|
||||
UpdateLabel(label *Label) (*Label, error)
|
||||
DeleteLabel(labelID string) error
|
||||
EmptyFolder(labelID string, addressID string) error
|
||||
ListLabels(ctx context.Context) ([]*Label, error)
|
||||
CreateLabel(ctx context.Context, label *Label) (*Label, error)
|
||||
UpdateLabel(ctx context.Context, label *Label) (*Label, error)
|
||||
DeleteLabel(ctx context.Context, labelID string) error
|
||||
EmptyFolder(ctx context.Context, labelID string, addressID string) error
|
||||
|
||||
Report(report ReportReq) error
|
||||
SendSimpleMetric(category, action, label string) error
|
||||
|
||||
GetMailSettings() (MailSettings, error)
|
||||
GetContactEmailByEmail(string, int, int) ([]ContactEmail, error)
|
||||
GetContactByID(string) (Contact, error)
|
||||
GetMailSettings(ctx context.Context) (MailSettings, error)
|
||||
GetContactEmailByEmail(context.Context, string, int, int) ([]ContactEmail, error)
|
||||
GetContactByID(context.Context, string) (Contact, error)
|
||||
DecryptAndVerifyCards([]Card) ([]Card, error)
|
||||
|
||||
GetAttachment(id string) (att io.ReadCloser, err error)
|
||||
CreateAttachment(att *Attachment, r io.Reader, sig io.Reader) (created *Attachment, err error)
|
||||
DeleteAttachment(attID string) (err error)
|
||||
GetAttachment(ctx context.Context, id string) (att io.ReadCloser, err error)
|
||||
CreateAttachment(ctx context.Context, att *Attachment, r io.Reader, sig io.Reader) (created *Attachment, err error)
|
||||
|
||||
KeyRingForAddressID(string) (kr *crypto.KeyRing, err error)
|
||||
GetPublicKeysForEmail(string) ([]PublicKey, bool, error)
|
||||
|
||||
DownloadAndVerify(string, string, *crypto.KeyRing) (io.Reader, error)
|
||||
GetPublicKeysForEmail(context.Context, string) ([]PublicKey, bool, error)
|
||||
}
|
||||
|
||||
type AuthHandler func(*Auth) error
|
||||
|
||||
type requester interface {
|
||||
r(context.Context) *resty.Request
|
||||
authRefresh(context.Context, string, string) (*Auth, error)
|
||||
}
|
||||
|
||||
@ -1,459 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/config/useragent"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const maxLogoutRetries = 5
|
||||
|
||||
// ClientManager is a manager of clients.
|
||||
type ClientManager struct { //nolint[maligned]
|
||||
// newClient is used to create new Clients. By default this creates pmapi clients but it can be overridden to
|
||||
// create other types of clients (e.g. for integration tests).
|
||||
newClient func(userID string) Client
|
||||
|
||||
config *ClientConfig
|
||||
userAgent *useragent.UserAgent
|
||||
roundTripper http.RoundTripper
|
||||
|
||||
clients map[string]Client
|
||||
clientsLocker sync.Locker
|
||||
cookieJar http.CookieJar
|
||||
|
||||
tokens map[string]string
|
||||
tokensLocker sync.Locker
|
||||
|
||||
expirations map[string]*tokenExpiration
|
||||
expiredTokens chan string
|
||||
expirationsLocker sync.Locker
|
||||
|
||||
authUpdates chan ClientAuth
|
||||
|
||||
host, scheme string
|
||||
hostLocker sync.RWMutex
|
||||
|
||||
allowProxy bool
|
||||
proxyProvider *proxyProvider
|
||||
proxyUseDuration time.Duration
|
||||
|
||||
idGen idGen
|
||||
|
||||
connectionOff bool
|
||||
|
||||
log *logrus.Entry
|
||||
}
|
||||
|
||||
type idGen int
|
||||
|
||||
func (i *idGen) next() int {
|
||||
(*i)++
|
||||
return int(*i)
|
||||
}
|
||||
|
||||
// ClientAuth holds an API auth produced by a Client for a specific user.
|
||||
type ClientAuth struct {
|
||||
UserID string
|
||||
Auth *Auth
|
||||
}
|
||||
|
||||
// tokenExpiration manages the expiration of an access token.
|
||||
type tokenExpiration struct {
|
||||
timer *time.Timer
|
||||
cancel chan (struct{})
|
||||
}
|
||||
|
||||
// NewClientManager creates a new ClientMan which manages clients configured with the given client config.
|
||||
func NewClientManager(config *ClientConfig, userAgent *useragent.UserAgent) (cm *ClientManager) {
|
||||
cm = &ClientManager{
|
||||
config: config,
|
||||
userAgent: userAgent,
|
||||
roundTripper: http.DefaultTransport,
|
||||
|
||||
clients: make(map[string]Client),
|
||||
clientsLocker: &sync.Mutex{},
|
||||
|
||||
tokens: make(map[string]string),
|
||||
tokensLocker: &sync.Mutex{},
|
||||
|
||||
expirations: make(map[string]*tokenExpiration),
|
||||
expiredTokens: make(chan string),
|
||||
expirationsLocker: &sync.Mutex{},
|
||||
|
||||
host: rootURL,
|
||||
scheme: rootScheme,
|
||||
hostLocker: sync.RWMutex{},
|
||||
|
||||
authUpdates: make(chan ClientAuth),
|
||||
|
||||
proxyProvider: newProxyProvider(dohProviders, proxyQuery),
|
||||
proxyUseDuration: proxyUseDuration,
|
||||
|
||||
connectionOff: false,
|
||||
|
||||
log: logrus.WithField("pkg", "pmapi-manager"),
|
||||
}
|
||||
|
||||
cm.newClient = func(userID string) Client {
|
||||
return newClient(cm, userID)
|
||||
}
|
||||
|
||||
go cm.watchTokenExpirations()
|
||||
|
||||
return cm
|
||||
}
|
||||
|
||||
func (cm *ClientManager) noConnection() {
|
||||
cm.log.Trace("No connection available")
|
||||
if cm.connectionOff {
|
||||
return
|
||||
}
|
||||
|
||||
cm.log.Warn("Connection lost")
|
||||
if cm.config.ConnectionOffHandler != nil {
|
||||
cm.config.ConnectionOffHandler()
|
||||
}
|
||||
cm.connectionOff = true
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
if err := cm.CheckConnection(); err == nil {
|
||||
cm.log.Info("Connection re-established")
|
||||
if cm.config.ConnectionOnHandler != nil {
|
||||
cm.config.ConnectionOnHandler()
|
||||
}
|
||||
cm.connectionOff = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SetClientConstructor sets the method used to construct clients.
|
||||
// By default this is `pmapi.newClient` but can be overridden with this method.
|
||||
func (cm *ClientManager) SetClientConstructor(f func(userID string) Client) {
|
||||
cm.newClient = f
|
||||
}
|
||||
|
||||
// SetCookieJar sets the cookie jar given to clients.
|
||||
func (cm *ClientManager) SetCookieJar(jar http.CookieJar) {
|
||||
cm.cookieJar = jar
|
||||
}
|
||||
|
||||
// SetRoundTripper sets the roundtripper used by clients created by this client manager.
|
||||
func (cm *ClientManager) SetRoundTripper(rt http.RoundTripper) {
|
||||
cm.roundTripper = rt
|
||||
}
|
||||
|
||||
func (cm *ClientManager) GetAppVersion() string {
|
||||
return cm.config.AppVersion
|
||||
}
|
||||
|
||||
func (cm *ClientManager) GetUserAgent() string {
|
||||
return cm.userAgent.String()
|
||||
}
|
||||
|
||||
// GetClient returns a client for the given userID.
|
||||
// If the client does not exist already, it is created.
|
||||
func (cm *ClientManager) GetClient(userID string) Client {
|
||||
cm.clientsLocker.Lock()
|
||||
defer cm.clientsLocker.Unlock()
|
||||
|
||||
if client, ok := cm.clients[userID]; ok {
|
||||
return client
|
||||
}
|
||||
|
||||
client := cm.newClient(userID)
|
||||
|
||||
cm.clients[userID] = client
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// GetAnonymousClient returns an anonymous client.
|
||||
func (cm *ClientManager) GetAnonymousClient() Client {
|
||||
return cm.GetClient(fmt.Sprintf("anonymous-%v", cm.idGen.next()))
|
||||
}
|
||||
|
||||
// LogoutClient logs out the client with the given userID and ensures its sensitive data is successfully cleared.
|
||||
func (cm *ClientManager) LogoutClient(userID string) {
|
||||
cm.clientsLocker.Lock()
|
||||
defer cm.clientsLocker.Unlock()
|
||||
|
||||
client, ok := cm.clients[userID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(cm.clients, userID)
|
||||
|
||||
go func() {
|
||||
defer client.ClearData()
|
||||
defer cm.clearToken(userID)
|
||||
|
||||
if strings.HasPrefix(userID, "anonymous-") {
|
||||
return
|
||||
}
|
||||
|
||||
var retries int
|
||||
|
||||
for client.DeleteAuth() == ErrAPINotReachable {
|
||||
retries++
|
||||
|
||||
if retries > maxLogoutRetries {
|
||||
cm.log.Error("Failed to delete client auth (retried too many times)")
|
||||
break
|
||||
}
|
||||
|
||||
cm.log.Warn("Failed to delete client auth because API was not reachable, retrying...")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetRootURL returns the full root URL (scheme+host).
|
||||
func (cm *ClientManager) GetRootURL() string {
|
||||
cm.hostLocker.RLock()
|
||||
defer cm.hostLocker.RUnlock()
|
||||
|
||||
return fmt.Sprintf("%v://%v", cm.scheme, cm.host)
|
||||
}
|
||||
|
||||
// getHost returns the host to make requests to.
|
||||
// It does not include the protocol i.e. no "https://" (use getScheme for that).
|
||||
func (cm *ClientManager) getHost() string {
|
||||
cm.hostLocker.RLock()
|
||||
defer cm.hostLocker.RUnlock()
|
||||
|
||||
return cm.host
|
||||
}
|
||||
|
||||
// IsProxyAllowed returns whether the user has allowed us to switch to a proxy if need be.
|
||||
func (cm *ClientManager) IsProxyAllowed() bool {
|
||||
cm.hostLocker.RLock()
|
||||
defer cm.hostLocker.RUnlock()
|
||||
|
||||
return cm.allowProxy
|
||||
}
|
||||
|
||||
// AllowProxy allows the client manager to switch clients over to a proxy if need be.
|
||||
func (cm *ClientManager) AllowProxy() {
|
||||
cm.hostLocker.Lock()
|
||||
defer cm.hostLocker.Unlock()
|
||||
|
||||
cm.allowProxy = true
|
||||
}
|
||||
|
||||
// DisallowProxy prevents the client manager from switching clients over to a proxy if need be.
|
||||
func (cm *ClientManager) DisallowProxy() {
|
||||
cm.hostLocker.Lock()
|
||||
defer cm.hostLocker.Unlock()
|
||||
|
||||
cm.allowProxy = false
|
||||
cm.host = rootURL
|
||||
|
||||
for _, client := range cm.clients {
|
||||
client.CloseConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// IsProxyEnabled returns whether we are currently proxying requests.
|
||||
func (cm *ClientManager) IsProxyEnabled() bool {
|
||||
cm.hostLocker.RLock()
|
||||
defer cm.hostLocker.RUnlock()
|
||||
|
||||
return cm.host != rootURL
|
||||
}
|
||||
|
||||
// switchToReachableServer switches to using a reachable server (either proxy or standard API).
|
||||
func (cm *ClientManager) switchToReachableServer() (proxy string, err error) {
|
||||
cm.hostLocker.Lock()
|
||||
defer cm.hostLocker.Unlock()
|
||||
|
||||
logrus.Info("Attempting to switch to a proxy")
|
||||
|
||||
if proxy, err = cm.proxyProvider.findReachableServer(); err != nil {
|
||||
err = errors.Wrap(err, "failed to find a usable proxy")
|
||||
return
|
||||
}
|
||||
|
||||
// If the chosen proxy is the standard API, we want to use it but still show the troubleshooting screen.
|
||||
if proxy == rootURL {
|
||||
logrus.Info("The standard API is reachable again; connection drop was only intermittent")
|
||||
err = ErrAPINotReachable
|
||||
cm.host = proxy
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithField("proxy", proxy).Info("Switching to a proxy")
|
||||
|
||||
// If the host is currently the rootURL, it's the first time we are enabling a proxy.
|
||||
// This means we want to disable it again in 24 hours.
|
||||
if cm.host == rootURL {
|
||||
go func() {
|
||||
<-time.After(cm.proxyUseDuration)
|
||||
|
||||
cm.hostLocker.Lock()
|
||||
defer cm.hostLocker.Unlock()
|
||||
|
||||
cm.host = rootURL
|
||||
}()
|
||||
}
|
||||
|
||||
cm.host = proxy
|
||||
|
||||
return proxy, err
|
||||
}
|
||||
|
||||
// GetToken returns the token for the given userID.
|
||||
func (cm *ClientManager) GetToken(userID string) string {
|
||||
cm.tokensLocker.Lock()
|
||||
defer cm.tokensLocker.Unlock()
|
||||
|
||||
return cm.tokens[userID]
|
||||
}
|
||||
|
||||
// GetAuthUpdateChannel returns a channel on which client auths can be received.
|
||||
func (cm *ClientManager) GetAuthUpdateChannel() chan ClientAuth {
|
||||
return cm.authUpdates
|
||||
}
|
||||
|
||||
// setTokenIfUnset sets the token for the given userID if it wasn't already set.
|
||||
// The set token does not expire.
|
||||
func (cm *ClientManager) setTokenIfUnset(userID, token string) {
|
||||
cm.tokensLocker.Lock()
|
||||
defer cm.tokensLocker.Unlock()
|
||||
|
||||
if _, ok := cm.tokens[userID]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithField("userID", userID).Info("Setting token because it is currently unset")
|
||||
|
||||
cm.tokens[userID] = token
|
||||
}
|
||||
|
||||
// setToken sets the token for the given userID with the given expiration time.
|
||||
func (cm *ClientManager) setToken(userID, token string, expiration time.Duration) {
|
||||
cm.tokensLocker.Lock()
|
||||
defer cm.tokensLocker.Unlock()
|
||||
|
||||
logrus.WithField("userID", userID).Info("Updating token")
|
||||
|
||||
cm.tokens[userID] = token
|
||||
|
||||
cm.setTokenExpiration(userID, expiration)
|
||||
}
|
||||
|
||||
// setTokenExpiration will ensure the token is refreshed if it expires.
|
||||
// If the token already has an expiration time set, it is replaced.
|
||||
func (cm *ClientManager) setTokenExpiration(userID string, expiration time.Duration) {
|
||||
cm.expirationsLocker.Lock()
|
||||
defer cm.expirationsLocker.Unlock()
|
||||
|
||||
// Reduce the expiration by one minute so we can do the refresh with enough time to spare.
|
||||
expiration -= time.Minute
|
||||
|
||||
if exp, ok := cm.expirations[userID]; ok {
|
||||
exp.timer.Stop()
|
||||
close(exp.cancel)
|
||||
}
|
||||
|
||||
cm.expirations[userID] = &tokenExpiration{
|
||||
timer: time.NewTimer(expiration),
|
||||
cancel: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func(expiration *tokenExpiration) {
|
||||
select {
|
||||
case <-expiration.timer.C:
|
||||
cm.expiredTokens <- userID
|
||||
|
||||
case <-expiration.cancel:
|
||||
logrus.WithField("userID", userID).Debug("Auth was refreshed before it expired")
|
||||
}
|
||||
}(cm.expirations[userID])
|
||||
}
|
||||
|
||||
func (cm *ClientManager) clearToken(userID string) {
|
||||
cm.tokensLocker.Lock()
|
||||
defer cm.tokensLocker.Unlock()
|
||||
|
||||
logrus.WithField("userID", userID).Debug("Clearing token")
|
||||
|
||||
delete(cm.tokens, userID)
|
||||
}
|
||||
|
||||
// HandleAuth updates or clears client authorisation based on auths received and then forwards the auth onwards.
|
||||
func (cm *ClientManager) HandleAuth(ca ClientAuth) {
|
||||
cm.clientsLocker.Lock()
|
||||
defer cm.clientsLocker.Unlock()
|
||||
|
||||
// If we aren't managing this client, there's nothing to do.
|
||||
if _, ok := cm.clients[ca.UserID]; !ok {
|
||||
logrus.WithField("userID", ca.UserID).Info("Not handling auth for unmanaged client")
|
||||
return
|
||||
}
|
||||
|
||||
// If the auth is nil, we should clear the token.
|
||||
if ca.Auth == nil {
|
||||
cm.clearToken(ca.UserID)
|
||||
go cm.LogoutClient(ca.UserID)
|
||||
} else {
|
||||
cm.setToken(ca.UserID, ca.Auth.GenToken(), time.Duration(ca.Auth.ExpiresIn)*time.Second)
|
||||
}
|
||||
|
||||
logrus.Debug("ClientManager is forwarding auth update...")
|
||||
cm.authUpdates <- ca
|
||||
logrus.Debug("Auth update was forwarded")
|
||||
}
|
||||
|
||||
// watchTokenExpirations refreshes any tokens which are about to expire.
|
||||
func (cm *ClientManager) watchTokenExpirations() {
|
||||
for userID := range cm.expiredTokens {
|
||||
log := cm.log.WithField("userID", userID)
|
||||
|
||||
log.Info("Auth token expired! Refreshing")
|
||||
|
||||
client, ok := cm.clients[userID]
|
||||
if !ok {
|
||||
log.Warn("Can't refresh expired token because there is no such client")
|
||||
continue
|
||||
}
|
||||
|
||||
token, ok := cm.tokens[userID]
|
||||
if !ok {
|
||||
log.Warn("Can't refresh expired token because there is no such token")
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := client.AuthRefresh(token); err != nil {
|
||||
log.WithError(err).Error("Failed to refresh expired token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import "github.com/ProtonMail/proton-bridge/internal/config/useragent"
|
||||
|
||||
func newTestClientManager(cfg *ClientConfig) *ClientManager {
|
||||
cm := NewClientManager(cfg, useragent.New())
|
||||
|
||||
go func() {
|
||||
for range cm.authUpdates {
|
||||
}
|
||||
}()
|
||||
|
||||
return cm
|
||||
}
|
||||
@ -1,55 +1,11 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rootURL is the API root URL.
|
||||
// It must not contain the protocol! The protocol should be in rootScheme.
|
||||
var rootURL = "api.protonmail.ch" //nolint[gochecknoglobals]
|
||||
|
||||
// rootScheme is the scheme to use for connections to the root URL.
|
||||
var rootScheme = "https" //nolint[gochecknoglobals]
|
||||
|
||||
func GetAPIConfig(configName, appVersion string) *ClientConfig {
|
||||
return &ClientConfig{
|
||||
AppVersion: getAPIOS() + strings.Title(configName) + "_" + appVersion,
|
||||
ClientID: configName,
|
||||
Timeout: 25 * time.Minute, // Overall request timeout (~25MB / 25 mins => ~16kB/s, should be reasonable).
|
||||
FirstReadTimeout: 30 * time.Second, // 30s to match 30s response header timeout.
|
||||
MinBytesPerSecond: 1 << 10, // Enforce minimum download speed of 1kB/s.
|
||||
}
|
||||
type Config struct {
|
||||
HostURL string
|
||||
AppVersion string
|
||||
}
|
||||
|
||||
// getAPIOS returns actual operating system.
|
||||
func getAPIOS() string {
|
||||
switch os := runtime.GOOS; os {
|
||||
case "darwin": // nolint: goconst
|
||||
return "macOS"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
case "windows":
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
return "Linux"
|
||||
var DefaultConfig = Config{
|
||||
HostURL: "https://api.protonmail.ch",
|
||||
AppVersion: "Other",
|
||||
}
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// +build !build_qa
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
||||
)
|
||||
|
||||
func GetRoundTripper(cm *ClientManager, listener listener.Listener) http.RoundTripper {
|
||||
// We use a TLS dialer.
|
||||
basicDialer := NewBasicTLSDialer()
|
||||
|
||||
// We wrap the TLS dialer in a layer which enforces connections to trusted servers.
|
||||
pinningDialer := NewPinningTLSDialer(basicDialer)
|
||||
|
||||
// We want any pin mismatches to be communicated back to bridge GUI and reported.
|
||||
pinningDialer.SetTLSIssueNotifier(func() { listener.Emit(events.TLSCertIssue, "") })
|
||||
pinningDialer.EnableRemoteTLSIssueReporting(cm)
|
||||
|
||||
// We wrap the pinning dialer in a layer which adds "alternative routing" feature.
|
||||
proxyDialer := NewProxyTLSDialer(pinningDialer, cm)
|
||||
|
||||
return CreateTransportWithDialer(proxyDialer)
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// +build build_qa
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// This config allows to dynamically change ROOT URL.
|
||||
fullRootURL := os.Getenv("PMAPI_ROOT_URL")
|
||||
if strings.HasPrefix(fullRootURL, "http") {
|
||||
rootURLparts := strings.SplitN(fullRootURL, "://", 2)
|
||||
rootScheme = rootURLparts[0]
|
||||
rootURL = rootURLparts[1]
|
||||
} else if fullRootURL != "" {
|
||||
rootURL = fullRootURL
|
||||
rootScheme = "https"
|
||||
}
|
||||
}
|
||||
|
||||
func GetRoundTripper(_ *ClientManager, _ listener.Listener) http.RoundTripper {
|
||||
transport := CreateTransportWithDialer(NewBasicTLSDialer())
|
||||
|
||||
// TLS certificate of testing environment might be self-signed.
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
return transport
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
// ConnectionReporter provides a way to report when internet connection is lost.
|
||||
type ConnectionReporter interface {
|
||||
NotifyConnectionLost() error
|
||||
}
|
||||
@ -18,9 +18,11 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Card struct {
|
||||
@ -105,322 +107,40 @@ func (c *client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// ====================== READ ===========================
|
||||
|
||||
type ContactsListRes struct {
|
||||
Res
|
||||
Contacts []*Contact
|
||||
}
|
||||
|
||||
// GetContacts gets all contacts.
|
||||
func (c *client) GetContacts(page int, pageSize int) (contacts []*Contact, err error) {
|
||||
v := url.Values{}
|
||||
v.Set("Page", strconv.Itoa(page))
|
||||
if pageSize > 0 {
|
||||
v.Set("PageSize", strconv.Itoa(pageSize))
|
||||
}
|
||||
req, err := c.NewRequest("GET", "/contacts?"+v.Encode(), nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res ContactsListRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
contacts, err = res.Contacts, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// GetContactByID gets contact details specified by contact ID.
|
||||
func (c *client) GetContactByID(id string) (contactDetail Contact, err error) {
|
||||
req, err := c.NewRequest("GET", "/contacts/"+id, nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type ContactRes struct {
|
||||
Res
|
||||
func (c *client) GetContactByID(ctx context.Context, contactID string) (contactDetail Contact, err error) {
|
||||
var res struct {
|
||||
Contact Contact
|
||||
}
|
||||
var res ContactRes
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/contacts/v4/" + contactID)
|
||||
}); err != nil {
|
||||
return Contact{}, err
|
||||
}
|
||||
|
||||
contactDetail, err = res.Contact, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// GetContactsForExport gets contacts in vCard format, signed and encrypted.
|
||||
func (c *client) GetContactsForExport(page int, pageSize int) (contacts []Contact, err error) {
|
||||
v := url.Values{}
|
||||
v.Set("Page", strconv.Itoa(page))
|
||||
if pageSize > 0 {
|
||||
v.Set("PageSize", strconv.Itoa(pageSize))
|
||||
}
|
||||
|
||||
req, err := c.NewRequest("GET", "/contacts/export?"+v.Encode(), nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type ContactsDetailsRes struct {
|
||||
Res
|
||||
Contacts []Contact
|
||||
}
|
||||
var res ContactsDetailsRes
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
contacts, err = res.Contacts, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type ContactsEmailsRes struct {
|
||||
Res
|
||||
ContactEmails []ContactEmail
|
||||
Total int
|
||||
}
|
||||
|
||||
// GetAllContactsEmails gets all emails from all contacts.
|
||||
func (c *client) GetAllContactsEmails(page int, pageSize int) (contactsEmails []ContactEmail, err error) {
|
||||
v := url.Values{}
|
||||
v.Set("Page", strconv.Itoa(page))
|
||||
if pageSize > 0 {
|
||||
v.Set("PageSize", strconv.Itoa(pageSize))
|
||||
}
|
||||
|
||||
req, err := c.NewRequest("GET", "/contacts/emails?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res ContactsEmailsRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
contactsEmails, err = res.ContactEmails, res.Err()
|
||||
return
|
||||
return res.Contact, nil
|
||||
}
|
||||
|
||||
// GetContactEmailByEmail gets all emails from all contacts matching a specified email string.
|
||||
func (c *client) GetContactEmailByEmail(email string, page int, pageSize int) (contactEmails []ContactEmail, err error) {
|
||||
v := url.Values{}
|
||||
v.Set("Page", strconv.Itoa(page))
|
||||
if pageSize > 0 {
|
||||
v.Set("PageSize", strconv.Itoa(pageSize))
|
||||
}
|
||||
v.Set("Email", email)
|
||||
|
||||
req, err := c.NewRequest("GET", "/contacts/emails?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) GetContactEmailByEmail(ctx context.Context, email string, page int, pageSize int) (contactEmails []ContactEmail, err error) {
|
||||
var res struct {
|
||||
ContactEmails []ContactEmail
|
||||
}
|
||||
|
||||
var res ContactsEmailsRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetQueryParams(map[string]string{
|
||||
"Email": email,
|
||||
"Page": strconv.Itoa(page),
|
||||
"PageSize": strconv.Itoa(pageSize),
|
||||
}).SetResult(&res).Get("/contacts/v4")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contactEmails, err = res.ContactEmails, res.Err()
|
||||
return
|
||||
return res.ContactEmails, nil
|
||||
}
|
||||
|
||||
// ============================ CREATE ====================================
|
||||
|
||||
type CardsList struct {
|
||||
Cards []Card
|
||||
}
|
||||
|
||||
type ContactsCards struct {
|
||||
Contacts []CardsList
|
||||
}
|
||||
|
||||
type SingleContactResponse struct {
|
||||
Res
|
||||
Contact Contact
|
||||
}
|
||||
|
||||
type IndexedContactResponse struct {
|
||||
Index int
|
||||
Response SingleContactResponse
|
||||
}
|
||||
|
||||
type AddContactsResponse struct {
|
||||
Res
|
||||
Responses []IndexedContactResponse
|
||||
}
|
||||
|
||||
type AddContactsReq struct {
|
||||
ContactsCards
|
||||
Overwrite int
|
||||
Groups int
|
||||
Labels int
|
||||
}
|
||||
|
||||
// AddContacts adds contacts specified by cards. Performs signing and encrypting based on card type.
|
||||
func (c *client) AddContacts(cards ContactsCards, overwrite int, groups int, labels int) (res *AddContactsResponse, err error) {
|
||||
reqBody := AddContactsReq{
|
||||
ContactsCards: cards,
|
||||
Overwrite: overwrite,
|
||||
Groups: groups,
|
||||
Labels: labels,
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/contacts", reqBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var addContactsRes AddContactsResponse
|
||||
if err = c.DoJSON(req, &addContactsRes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res, err = &addContactsRes, addContactsRes.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// ================================= UPDATE =======================================
|
||||
|
||||
type UpdateContactResponse struct {
|
||||
Res
|
||||
Contact Contact
|
||||
}
|
||||
|
||||
type UpdateContactReq struct {
|
||||
Cards []Card
|
||||
}
|
||||
|
||||
// UpdateContact updates contact identified by contact ID. Modified contact is specified by cards.
|
||||
func (c *client) UpdateContact(id string, cards []Card) (res *UpdateContactResponse, err error) {
|
||||
reqBody := UpdateContactReq{
|
||||
Cards: cards,
|
||||
}
|
||||
req, err := c.NewJSONRequest("PUT", "/contacts/"+id, reqBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var updateContactRes UpdateContactResponse
|
||||
if err = c.DoJSON(req, &updateContactRes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res, err = &updateContactRes, updateContactRes.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type SingleIDResponse struct {
|
||||
Res
|
||||
ID string
|
||||
}
|
||||
|
||||
type UpdateContactGroupsResponse struct {
|
||||
Res
|
||||
Response SingleIDResponse
|
||||
}
|
||||
|
||||
func (c *client) AddContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
|
||||
return c.modifyContactGroups(groupID, addContactGroupsAction, contactEmailIDs)
|
||||
}
|
||||
|
||||
func (c *client) RemoveContactGroups(groupID string, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
|
||||
return c.modifyContactGroups(groupID, removeContactGroupsAction, contactEmailIDs)
|
||||
}
|
||||
|
||||
const (
|
||||
removeContactGroupsAction = 0
|
||||
addContactGroupsAction = 1
|
||||
)
|
||||
|
||||
type ModifyContactGroupsReq struct {
|
||||
LabelID string
|
||||
Action int
|
||||
ContactEmailIDs []string
|
||||
}
|
||||
|
||||
func (c *client) modifyContactGroups(groupID string, modifyContactGroupsAction int, contactEmailIDs []string) (res *UpdateContactGroupsResponse, err error) {
|
||||
reqBody := ModifyContactGroupsReq{
|
||||
LabelID: groupID,
|
||||
Action: modifyContactGroupsAction,
|
||||
ContactEmailIDs: contactEmailIDs,
|
||||
}
|
||||
req, err := c.NewJSONRequest("PUT", "/contacts/group", reqBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// ================================= DELETE =======================================
|
||||
|
||||
type DeleteReq struct {
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// DeleteContacts deletes contacts specified by an array of contact IDs.
|
||||
func (c *client) DeleteContacts(ids []string) (err error) {
|
||||
deleteReq := DeleteReq{
|
||||
IDs: ids,
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("PUT", "/contacts/delete", deleteReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type DeleteContactsRes struct {
|
||||
Res
|
||||
Responses []struct {
|
||||
ID string
|
||||
Response Res
|
||||
}
|
||||
}
|
||||
var res DeleteContactsRes
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteAllContacts deletes all contacts.
|
||||
func (c *client) DeleteAllContacts() (err error) {
|
||||
req, err := c.NewRequest("DELETE", "/contacts", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ===================== Private utility methods =======================
|
||||
|
||||
func isSignedCardType(cardType int) bool {
|
||||
return (cardType & CardSigned) == CardSigned
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
@ -34,221 +34,6 @@ var (
|
||||
EncryptedSignedCard = 3
|
||||
)
|
||||
|
||||
var testAddContactsReq = AddContactsReq{
|
||||
ContactsCards: ContactsCards{
|
||||
Contacts: []CardsList{
|
||||
{
|
||||
Cards: []Card{
|
||||
{
|
||||
Type: 2,
|
||||
Data: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN;TYPE=fn:Bob
|
||||
item1.EMAIL:bob.tester@protonmail.com
|
||||
UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece
|
||||
END:VCARD
|
||||
`,
|
||||
Signature: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Overwrite: 0,
|
||||
Groups: 0,
|
||||
Labels: 0,
|
||||
}
|
||||
|
||||
var testAddContactsResponseBody = `{
|
||||
"Code": 1001,
|
||||
"Responses": [
|
||||
{
|
||||
"Index": 0,
|
||||
"Response": {
|
||||
"Code": 1000,
|
||||
"Contact": {
|
||||
"ID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
|
||||
"Name": "Bob",
|
||||
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
"Size": 139,
|
||||
"CreateTime": 1517319495,
|
||||
"ModifyTime": 1517319495,
|
||||
"ContactEmails": [
|
||||
{
|
||||
"ID": "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==",
|
||||
"Name": "Bob",
|
||||
"Email": "bob.tester@protonmail.com",
|
||||
"Type": [],
|
||||
"Defaults": 1,
|
||||
"Order": 1,
|
||||
"ContactID": "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
|
||||
"LabelIDs": []
|
||||
}
|
||||
],
|
||||
"LabelIDs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var testContactCreated = &AddContactsResponse{
|
||||
Res: Res{
|
||||
Code: 1001,
|
||||
StatusCode: 200,
|
||||
},
|
||||
Responses: []IndexedContactResponse{
|
||||
{
|
||||
Index: 0,
|
||||
Response: SingleContactResponse{
|
||||
Res: Res{
|
||||
Code: 1000,
|
||||
},
|
||||
Contact: Contact{
|
||||
ID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
|
||||
Name: "Bob",
|
||||
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
Size: 139,
|
||||
CreateTime: 1517319495,
|
||||
ModifyTime: 1517319495,
|
||||
ContactEmails: []ContactEmail{
|
||||
{
|
||||
ID: "VT4NoPeQPk48_vg0CVmk63n5mB6CZn9q-P_DYODhOUemhuzUkgBFGF1MktVArjX5zsVdfVlEBFObvt0_K5NwPg==",
|
||||
Name: "Bob",
|
||||
Email: "bob.tester@protonmail.com",
|
||||
Type: []string{},
|
||||
Defaults: 1,
|
||||
Order: 1,
|
||||
ContactID: "EU7qYvPAdgJ-zl53hw_btO1WG8TN2FYh2cTIFq1_T6KqulwgxF8CzPjVk_RBUdEejtLvfynlelVNoZwMK_9X2g==",
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
},
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var testContactUpdated = &UpdateContactResponse{
|
||||
Res: Res{
|
||||
Code: 1000,
|
||||
StatusCode: 200,
|
||||
},
|
||||
Contact: Contact{
|
||||
ID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
|
||||
Name: "Bob",
|
||||
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
Size: 303,
|
||||
CreateTime: 1517416603,
|
||||
ModifyTime: 1517416656,
|
||||
ContactEmails: []ContactEmail{
|
||||
{
|
||||
ID: "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==",
|
||||
Name: "Bob",
|
||||
Email: "bob.changed.tester@protonmail.com",
|
||||
Type: []string{},
|
||||
Defaults: 1,
|
||||
Order: 1,
|
||||
ContactID: "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
},
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestContact_AddContact(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/contacts"))
|
||||
|
||||
var addContactsReq AddContactsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&addContactsReq); err != nil {
|
||||
t.Error("Expecting no error while reading request body, got:", err)
|
||||
}
|
||||
if !reflect.DeepEqual(testAddContactsReq.ContactsCards, addContactsReq.ContactsCards) {
|
||||
t.Errorf("Invalid contacts request: expected %+v but got %+v", testAddContactsReq.ContactsCards, addContactsReq.ContactsCards)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testAddContactsResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
created, err := c.AddContacts(testAddContactsReq.ContactsCards, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while adding contact, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(created, testContactCreated) {
|
||||
t.Fatalf("Invalid created contact: expected %+v, got %+v", testContactCreated, created)
|
||||
}
|
||||
}
|
||||
|
||||
var testGetContactsResponseBody = `{
|
||||
"Code": 1000,
|
||||
"Contacts": [
|
||||
{
|
||||
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"Name": "Alice",
|
||||
"UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
|
||||
"Size": 243,
|
||||
"CreateTime": 1517395498,
|
||||
"ModifyTime": 1517395498,
|
||||
"LabelIDs": []
|
||||
},
|
||||
{
|
||||
"ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
"Name": "Bob",
|
||||
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
"Size": 303,
|
||||
"CreateTime": 1517394677,
|
||||
"ModifyTime": 1517394678,
|
||||
"LabelIDs": []
|
||||
}
|
||||
],
|
||||
"Total": 2
|
||||
}`
|
||||
|
||||
var testGetContacts = []*Contact{
|
||||
|
||||
{
|
||||
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
Name: "Alice",
|
||||
UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
|
||||
Size: 243,
|
||||
CreateTime: 1517395498,
|
||||
ModifyTime: 1517395498,
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
{
|
||||
ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
Name: "Bob",
|
||||
UID: "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
Size: 303,
|
||||
CreateTime: 1517394677,
|
||||
ModifyTime: 1517394678,
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestContact_GetContacts(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/contacts?Page=0&PageSize=1000"))
|
||||
|
||||
fmt.Fprint(w, testGetContactsResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
contacts, err := c.GetContacts(0, 1000)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(contacts, testGetContacts) {
|
||||
t.Fatalf("Invalid created contact: expected %+v, got %+v", testGetContacts, contacts)
|
||||
}
|
||||
}
|
||||
|
||||
var testGetContactByIDResponseBody = `{
|
||||
"Code": 1000,
|
||||
"Contact": {
|
||||
@ -321,14 +106,16 @@ var testGetContactByID = Contact{
|
||||
}
|
||||
|
||||
func TestContact_GetContactById(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/contacts/s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="))
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/contacts/v4/s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testGetContactByIDResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
contact, err := c.GetContactByID("s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==")
|
||||
contact, err := c.GetContactByID(context.TODO(), "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==")
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts, got:", err)
|
||||
}
|
||||
@ -338,287 +125,6 @@ func TestContact_GetContactById(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var testGetContactsForExportResponseBody = `{
|
||||
"Code": 1000,
|
||||
"Contacts": [
|
||||
{
|
||||
"ID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
"Cards": [
|
||||
{
|
||||
"Type": 2,
|
||||
"Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n"
|
||||
},
|
||||
{
|
||||
"Type": 3,
|
||||
"Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"Cards": [
|
||||
{
|
||||
"Type": 3,
|
||||
"Data": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n"
|
||||
},
|
||||
{
|
||||
"Type": 2,
|
||||
"Data": "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Total": 2
|
||||
}`
|
||||
|
||||
var testGetContactsForExport = []Contact{
|
||||
{
|
||||
ID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
Cards: []Card{
|
||||
{
|
||||
Type: 2,
|
||||
Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Bob\nitem1.EMAIL:bob.changed.tester@protonmail.com\nUID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece\nEND:VCARD\n",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAAtxwIAFGgPO+xH4PHppffQC1R\nxCp/Bjzaq5rDUE3ZMKVJ1sFqGVlq2bP5CIN4w2XCe/MuZ+z2o87fSEtt2n7i\n0/8Ah35u4czn7t8FZoW8u9WwHPURa8gUbP3fYpVASBY1Bt2fUxJrSUYn5KQp\njJM/DgF99bhIjOTuhx9IN7DFKG647Arq+GJh9M6RJNxkb3CBfcCVUXoIwMB7\nnM/fA1r+mcl8dQam0WKVJgy9aO2XUUR62w1SpqJlXY3z8hKvXjjskzU3DQk5\net07RLVQvhy2nCZePsM+TJzL8OBbTa1aF/p1xPe+HND7t3ZCm9tQOY+UhK5H\nbhPbQY48KGdci1dTcm2HbsQ=\n=iOnV\n-----END PGP SIGNATURE-----\n",
|
||||
},
|
||||
{
|
||||
Type: 3,
|
||||
Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQgABsQWnqZadqrHDN43McGhEYfJjOB66R5HhkQAUavP\nHaAHpJciGxfz6tbztQu4C6kdMA80ElbD8c+bJqalw6ZbT4seoP4TTQLykD1n\n0LuNBlaW4x8kfd8rZzFdckk/dY2PruX6byAjSZslnZlZSwp99AJJbvJtfXRR\nzunKMbDieRkaApGZYT25wT5mz1embpXFesvO4nDkOEQCa0uyti3mNSLhYlf/\ntbaOS3WM9VYM9eB9YRZGzJNxMtTxOsd45tBlGCHnCzWEUnJdqZuYzH2QOky7\nMckXhk6YwyemYi/q7OOgSYEg/0lCs2EK3b//14yPDx8Bj5G7rZrnDgsP+BHj\nu9KaAZb2pSBPQoJ2DY3Y4A2Sg8GjaX5CMO9D6GKJkZSYkXddQgcmw7sVPUS+\n+5JaPXlfxoJOOn9kj9A6LDC6eMhYaLujG1BKcZ16DB0jqfwMnPLJ+bYEdatr\nKMvd9rbdsDwQ/tfk11VvHpiEBCNZjxM2+bdBLl9q2EXaLXi+dz/rJg5C0A9u\nNS2CzCUvg6+jNUzHo/RBfRXvlNV8tw==\n=mE2b\n-----END PGP MESSAGE-----\n",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZr2CRDMO9BwcW4mpAAApucIAD/uwWuV6DOg127XIPG6\n/jluL8jmwyCJX9noL6S8ZVMOymziKSh4/P1QyMPC5SL4lMPEiuaEdyetfBkU\n+5hW3tcZ+ptxmDi59SVYqmXTVewPgeB7t8c5nbzCuVuzA7ZAo8HAXHzFVQDS\nj9fKVGjZzQkmlwdcfnkXHAF0Ejilv9wxOOYgqVDuzm7JXVF3Um7nAgGKTJE5\n5CNnrEjmJGapj96mQFwXzET/kAhNIBw9tL5FAkDlKImdw8C0w9sXdvDu3yVM\ntvUZ5o2rR6ft0SC1byFso49vgJ/syeK6P2pPzltZJbsp4MvmlPUB0/G1XRU+\nI7q4IOWCvs8RD88ADmOty2o=\n=hyZE\n-----END PGP SIGNATURE-----\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
Cards: []Card{
|
||||
{
|
||||
Type: 3,
|
||||
Data: "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMA1vYAFKnBP8gAQf/RnOQRpo8DVJHQSJRgckEaUQvdMcADiM4L23diyiS\nQfclby/Ve2WInmvZc2RJ3rWENfeqyDZE6krQT642pKiW09GOIyVIjl+hje9y\nE4HBX0AIAWv7QhhKX6UZcM5dYSFbV3j3QxQB8A4Thng2G6ltotMTlbtcHbhu\n96Lt6ngA1tngXLSF5seyflnoiSQ5gLi2qVzrd95dIP6D4Ottcp929/4hDGmq\nPyxw9dColx6gVd1bmIDSI6ewkET4Grmo6QYqjSvjqLOf0PqHKzqypSFLkI5l\nmmnWKYTQCgl9wX+hq6Qz5E+m/BtbkdeX0YxYUss2e+oSAzJmnfdETErG9U5z\n3NJqAc3sgdwDzfWHBzogAxAbDHiqrF6zMlR5SFvZ6nRU7M2DTOE5dJhf+zOp\n1WSKn5LR46LGyt0m5wJPDjaGyQdPffAO4EULvwhGENe10UxRjY1qcUmjYOtS\nunl/vh3afI9PC1jj+HHJD2VgCA==\n=UpcY\n-----END PGP MESSAGE-----\n",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4pCRDMO9BwcW4mpAAA6h0H/2+97koXzly5pu9hpbaW\n75d1Q976RjMr5DjAx6tKFtSzznel8YfWgvA6OQmMGdPY8ae7/+3mwCJZYWy/\nXVvUfCSflmYpSIKGfP+Vm1XezWY1W84DGhiFj5n8sdaWisv3bpFwFf1YR3Ae\noBoZ4ufNzaQALRqGPMgXETtXZCtzuL/+0vGSKj5SLECiRcSE4jCPEVRy2bcl\nWJyB9r4VmcjF042OMHxphXoYmTEWvgigyaQFHNORu5cK9EHfHpCG6IcjGbdx\n+9Px5YnDY1ix+YpBKePGSTlLE0u6ow0VTUrdvNjl7IUBaRcfJcIIdgCBOTMw\n1uQ/yeyP46V5AFXFnIKeZeQ=\n=FlOf\n-----END PGP SIGNATURE-----\n",
|
||||
},
|
||||
{
|
||||
Type: 2,
|
||||
Data: "BEGIN:VCARD\nVERSION:4.0\nFN;TYPE=fn:Alice\nitem1.EMAIL:alice@protonmail.com\nUID:proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b\nEND:VCARD",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAQBQJacZ4qCRDMO9BwcW4mpAAA3jUIAJ88mIyO8Yj0+evSFXnK\nNxNdjNgn7t1leY0BWlh1nkK76XrZEPipdw2QU8cOcZzn1Wby2SGfZVkwoPc4\nzAhPT4WKbkFVqXhDry5399kLwGYJCxdEcw/oPyYj+YgpQKMxhTrQq21tbEwr\n7JDRBXgi3Cckh/XsteFHOIiAVnM7BV6zFudipnYxa4uNF0Bf4VbUZx1Mm0Wb\nMJaGsO5reqQUQzDPO5TdSAZ8qGSdjVv7RESgUu5DckcDSsnB987Zbh9uFc22\nfPYmb6zA0cEZh3dAjpDPT7cg8hlvfYBb+kP3sLFyLiIkdEG8Pcagjf0k+l76\nr1IsPlYBx2LJmsJf+WDNlj8=\n=Xn+3\n-----END PGP SIGNATURE-----\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestContact_GetContactsForExport(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/contacts/export?Page=0&PageSize=1000"))
|
||||
|
||||
fmt.Fprint(w, testGetContactsForExportResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
contacts, err := c.GetContactsForExport(0, 1000)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts for export, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(contacts, testGetContactsForExport) {
|
||||
t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsForExport, contacts)
|
||||
}
|
||||
}
|
||||
|
||||
var testGetContactsEmailsResponseBody = `{
|
||||
"Code": 1000,
|
||||
"ContactEmails": [
|
||||
{
|
||||
"ID": "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==",
|
||||
"Name": "Bob",
|
||||
"Email": "bob.changed.tester@protonmail.com",
|
||||
"Type": [],
|
||||
"Defaults": 1,
|
||||
"Order": 1,
|
||||
"ContactID": "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
"LabelIDs": []
|
||||
},
|
||||
{
|
||||
"ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
|
||||
"Name": "Alice",
|
||||
"Email": "alice@protonmail.com",
|
||||
"Type": [],
|
||||
"Defaults": 1,
|
||||
"Order": 1,
|
||||
"ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"LabelIDs": []
|
||||
}
|
||||
],
|
||||
"Total": 2
|
||||
}`
|
||||
|
||||
var testGetContactsEmails = []ContactEmail{
|
||||
{
|
||||
ID: "Hgyz1tG0OiC2v_hMIVOa6juMOAp_recWNzWII7a79Tfwdx08Jy3FJY0_Y_UtFYwbi6mN-Xx1sOI9_GmUGAcwWg==",
|
||||
Name: "Bob",
|
||||
Email: "bob.changed.tester@protonmail.com",
|
||||
Type: []string{},
|
||||
Defaults: 1,
|
||||
Order: 1,
|
||||
ContactID: "c6CWuyEE6mMRApAxvvCO9MQKydTU8Do1iikL__M5MoWWjDEebzChAUx-73qa1jTV54RzFO5p9pLBPsIIgCwpww==",
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
{
|
||||
ID: "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
|
||||
Name: "Alice",
|
||||
Email: "alice@protonmail.com",
|
||||
Type: []string{},
|
||||
Defaults: 1,
|
||||
Order: 1,
|
||||
ContactID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
LabelIDs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestContact_GetAllContactsEmails(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/contacts/emails?Page=0&PageSize=1000"))
|
||||
|
||||
fmt.Fprint(w, testGetContactsEmailsResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
contactsEmails, err := c.GetAllContactsEmails(0, 1000)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts for export, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(contactsEmails, testGetContactsEmails) {
|
||||
t.Fatalf("Invalid contact for export: expected %+v, got %+v", testGetContactsEmails, contactsEmails)
|
||||
}
|
||||
}
|
||||
|
||||
var testUpdateContactReq = UpdateContactReq{
|
||||
Cards: []Card{
|
||||
{
|
||||
Type: 2,
|
||||
Data: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN;TYPE=fn:Bob
|
||||
item1.EMAIL:bob.changed.tester@protonmail.com
|
||||
UID:proton-web-cd974706-5cde-0e53-e131-c49c88a92ece
|
||||
END:VCARD
|
||||
`,
|
||||
Signature: ``,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var testUpdateContactResponseBody = `{
|
||||
"Code": 1000,
|
||||
"Contact": {
|
||||
"ID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
|
||||
"Name": "Bob",
|
||||
"UID": "proton-web-cd974706-5cde-0e53-e131-c49c88a92ece",
|
||||
"Size": 303,
|
||||
"CreateTime": 1517416603,
|
||||
"ModifyTime": 1517416656,
|
||||
"ContactEmails": [
|
||||
{
|
||||
"ID": "14n6vuf1zbeo3zsYzgV471S6xJ9gzl7-VZ8tcOTQq6ifBlNEre0SUdUM7sXh6e2Q_4NhJZaU9c7jLdB1HCV6dA==",
|
||||
"Name": "Bob",
|
||||
"Email": "bob.changed.tester@protonmail.com",
|
||||
"Type": [],
|
||||
"Defaults": 1,
|
||||
"Order": 1,
|
||||
"ContactID": "l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==",
|
||||
"LabelIDs": []
|
||||
}
|
||||
],
|
||||
"LabelIDs": []
|
||||
}
|
||||
}`
|
||||
|
||||
func TestContact_UpdateContact(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "PUT", "/contacts/l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ=="))
|
||||
|
||||
var updateContactReq UpdateContactReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&updateContactReq); err != nil {
|
||||
t.Error("Expecting no error while reading request body, got:", err)
|
||||
}
|
||||
if !reflect.DeepEqual(testUpdateContactReq.Cards, updateContactReq.Cards) {
|
||||
t.Errorf("Invalid contacts request: expected %+v but got %+v", testUpdateContactReq.Cards, updateContactReq.Cards)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testUpdateContactResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
created, err := c.UpdateContact("l4PrVkmDsIIDba9aln829uwPK0nnyWZHnFtrsyb7CJsYgrD6JTVTuuoaVmaANfO2jIVxzZ2vtbt74rznGjjwFQ==", testUpdateContactReq.Cards)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while updating contact, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(created, testContactUpdated) {
|
||||
t.Fatalf("Invalid updated contact: expected\n%+v\ngot\n%+v\n", testContactUpdated, created)
|
||||
}
|
||||
}
|
||||
|
||||
var testDeleteContactsReq = DeleteReq{
|
||||
IDs: []string{
|
||||
"s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
},
|
||||
}
|
||||
|
||||
var testDeleteContactsResponseBody = `{
|
||||
"Code": 1001,
|
||||
"Responses": [
|
||||
{
|
||||
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"Response": {
|
||||
"Code": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestContact_DeleteContacts(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "PUT", "/contacts/delete"))
|
||||
|
||||
var deleteContactsReq DeleteReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&deleteContactsReq); err != nil {
|
||||
t.Error("Expecting no error while reading request body, got:", err)
|
||||
}
|
||||
if !reflect.DeepEqual(testDeleteContactsReq.IDs, deleteContactsReq.IDs) {
|
||||
t.Errorf("Invalid delete contacts request: expected %+v but got %+v", deleteContactsReq.IDs, testDeleteContactsReq.IDs)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testDeleteContactsResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.DeleteContacts([]string{"s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg=="})
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts for export, got:", err)
|
||||
}
|
||||
}
|
||||
|
||||
var testDeleteAllResponseBody = `{
|
||||
"Code": 1000
|
||||
}`
|
||||
|
||||
func TestContact_DeleteAllContacts(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/contacts"))
|
||||
|
||||
fmt.Fprint(w, testDeleteAllResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.DeleteAllContacts()
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts for export, got:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContact_isSignedCardType(t *testing.T) {
|
||||
if !isSignedCardType(SignedCard) || !isSignedCardType(EncryptedSignedCard) {
|
||||
t.Fatal("isSignedCardType shouldn't return false for signed card types")
|
||||
@ -654,7 +160,7 @@ var testCardsCleartext = []Card{
|
||||
}
|
||||
|
||||
func TestClient_Encrypt(t *testing.T) {
|
||||
c := newTestClient(newTestClientManager(testClientConfig))
|
||||
c := newClient(newManager(DefaultConfig), "")
|
||||
c.userKeyRing = testPrivateKeyRing
|
||||
|
||||
cardEncrypted, err := c.EncryptAndSignCards(testCardsCleartext)
|
||||
@ -668,7 +174,7 @@ func TestClient_Encrypt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_Decrypt(t *testing.T) {
|
||||
c := newTestClient(newTestClientManager(testClientConfig))
|
||||
c := newClient(newManager(DefaultConfig), "")
|
||||
c.userKeyRing = testPrivateKeyRing
|
||||
|
||||
cardCleartext, err := c.DecryptAndVerifyCards(testCardsEncrypted)
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
// ConversationsCount have same structure as MessagesCount.
|
||||
type ConversationsCount MessagesCount
|
||||
|
||||
// ConversationsCountsRes holds response from server.
|
||||
type ConversationsCountsRes struct {
|
||||
Res
|
||||
|
||||
Counts []*ConversationsCount
|
||||
}
|
||||
|
||||
// Conversation contains one body and multiple metadata.
|
||||
type Conversation struct{}
|
||||
|
||||
// CountConversations counts conversations by label.
|
||||
func (c *client) CountConversations(addressID string) (counts []*ConversationsCount, err error) {
|
||||
reqURL := "/mail/v4/conversations/count"
|
||||
if addressID != "" {
|
||||
reqURL += ("?AddressID=" + addressID)
|
||||
}
|
||||
req, err := c.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res ConversationsCountsRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
counts, err = res.Counts, res.Err()
|
||||
return
|
||||
}
|
||||
19
pkg/pmapi/data_test.go
Normal file
19
pkg/pmapi/data_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package pmapi
|
||||
|
||||
import "github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
|
||||
var testIdentity = &crypto.Identity{
|
||||
Name: "UserID",
|
||||
Email: "",
|
||||
}
|
||||
|
||||
const (
|
||||
testUsername = "jason"
|
||||
testAPIPassword = "apple"
|
||||
|
||||
testUID = "729ad6012421d67ad26950dc898bebe3a6e3caa2" //nolint[gosec]
|
||||
testAccessToken = "de0423049b44243afeec7d9c1d99be7b46da1e8a" //nolint[gosec]
|
||||
testAccessTokenOld = "feb3159ac63fb05119bcf4480d939278aa746926" //nolint[gosec]
|
||||
testRefreshToken = "a49b98256745bb497bec20e9b55f5de16f01fb52" //nolint[gosec]
|
||||
testRefreshTokenNew = "b894b4c4f20003f12d486900d8b88c7d68e67235" //nolint[gosec]
|
||||
)
|
||||
@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
func printBytes(body []byte) string {
|
||||
if utf8.Valid(body) {
|
||||
return string(body)
|
||||
}
|
||||
enc := []rune{}
|
||||
for _, b := range body {
|
||||
switch {
|
||||
case b == 9:
|
||||
enc = append(enc, rune('⟼'))
|
||||
case b == 13:
|
||||
enc = append(enc, rune('↵'))
|
||||
case b < 32, b == 127:
|
||||
enc = append(enc, '◡')
|
||||
case b > 31 && b < 127, b == 10:
|
||||
enc = append(enc, rune(b))
|
||||
default:
|
||||
enc = append(enc, 9728+rune(b))
|
||||
}
|
||||
}
|
||||
|
||||
return string(enc)
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TLSDialer interface {
|
||||
DialTLS(network, address string) (conn net.Conn, err error)
|
||||
}
|
||||
|
||||
// CreateTransportWithDialer creates an http.Transport that uses the given dialer to make TLS connections.
|
||||
func CreateTransportWithDialer(dialer TLSDialer) *http.Transport {
|
||||
return &http.Transport{
|
||||
DialTLS: dialer.DialTLS,
|
||||
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 5 * time.Minute,
|
||||
ExpectContinueTimeout: 500 * time.Millisecond,
|
||||
|
||||
// GODT-126: this was initially 10s but logs from users showed a significant number
|
||||
// were hitting this timeout, possibly due to flaky wifi taking >10s to reconnect.
|
||||
// Bumping to 30s for now to avoid this problem.
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
|
||||
// If we allow up to 30 seconds for response headers, it is reasonable to allow up
|
||||
// to 30 seconds for the TLS handshake to take place.
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// BasicTLSDialer implements TLSDialer.
|
||||
type BasicTLSDialer struct{}
|
||||
|
||||
// NewBasicTLSDialer returns a new BasicTLSDialer.
|
||||
func NewBasicTLSDialer() *BasicTLSDialer {
|
||||
return &BasicTLSDialer{}
|
||||
}
|
||||
|
||||
// DialTLS returns a connection to the given address using the given network.
|
||||
func (b *BasicTLSDialer) DialTLS(network, address string) (conn net.Conn, err error) {
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second} // Alternative Routes spec says this should be a 30s timeout.
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
|
||||
// If we are not dialing the standard API then we should skip cert verification checks.
|
||||
if address != rootURL {
|
||||
tlsConfig = &tls.Config{InsecureSkipVerify: true} // nolint[gosec]
|
||||
}
|
||||
|
||||
return tls.DialWithDialer(dialer, network, address, tlsConfig)
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// PinningTLSDialer wraps a TLSDialer to check fingerprints after connecting and
|
||||
// to report errors if the fingerprint check fails.
|
||||
type PinningTLSDialer struct {
|
||||
dialer TLSDialer
|
||||
|
||||
// pinChecker is used to check TLS keys of connections.
|
||||
pinChecker *pinChecker
|
||||
|
||||
// tlsIssueNotifier is used to notify something when there is a TLS issue.
|
||||
tlsIssueNotifier func()
|
||||
|
||||
reporter *tlsReporter
|
||||
|
||||
// A logger for logging messages.
|
||||
log logrus.FieldLogger
|
||||
}
|
||||
|
||||
// NewPinningTLSDialer constructs a new dialer which only returns tcp connections to servers
|
||||
// which present known certificates.
|
||||
// If enabled, it reports any invalid certificates it finds.
|
||||
func NewPinningTLSDialer(dialer TLSDialer) *PinningTLSDialer {
|
||||
return &PinningTLSDialer{
|
||||
dialer: dialer,
|
||||
pinChecker: newPinChecker(TrustedAPIPins),
|
||||
log: logrus.WithField("pkg", "pmapi/tls-pinning"),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PinningTLSDialer) SetTLSIssueNotifier(notifier func()) {
|
||||
p.tlsIssueNotifier = notifier
|
||||
}
|
||||
|
||||
func (p *PinningTLSDialer) EnableRemoteTLSIssueReporting(cm *ClientManager) {
|
||||
p.reporter = newTLSReporter(p.pinChecker, cm)
|
||||
}
|
||||
|
||||
// DialTLS dials the given network/address, returning an error if the certificates don't match the trusted pins.
|
||||
func (p *PinningTLSDialer) DialTLS(network, address string) (net.Conn, error) {
|
||||
conn, err := p.dialer.DialTLS(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.pinChecker.checkCertificate(conn); err != nil {
|
||||
if p.tlsIssueNotifier != nil {
|
||||
go p.tlsIssueNotifier()
|
||||
}
|
||||
|
||||
if tlsConn, ok := conn.(*tls.Conn); ok && p.reporter != nil {
|
||||
p.reporter.reportCertIssue(
|
||||
TLSReportURI,
|
||||
host,
|
||||
port,
|
||||
tlsConn.ConnectionState(),
|
||||
)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const liveAPI = "api.protonmail.ch"
|
||||
|
||||
var testLiveConfig = &ClientConfig{
|
||||
AppVersion: "Bridge_1.2.4-test",
|
||||
ClientID: "Bridge",
|
||||
}
|
||||
|
||||
func createAndSetPinningDialer(cm *ClientManager) (*int, *PinningTLSDialer) {
|
||||
called := 0
|
||||
|
||||
dialer := NewPinningTLSDialer(NewBasicTLSDialer())
|
||||
dialer.SetTLSIssueNotifier(func() { called++ })
|
||||
cm.SetRoundTripper(CreateTransportWithDialer(dialer))
|
||||
|
||||
return &called, dialer
|
||||
}
|
||||
|
||||
func TestTLSPinValid(t *testing.T) {
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
cm.host = liveAPI
|
||||
rootScheme = "https"
|
||||
called, _ := createAndSetPinningDialer(cm)
|
||||
client := cm.GetClient("pmapi" + t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 0, *called)
|
||||
}
|
||||
|
||||
func TestTLSPinBackup(t *testing.T) {
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
cm.host = liveAPI
|
||||
called, p := createAndSetPinningDialer(cm)
|
||||
p.pinChecker.trustedPins[1] = p.pinChecker.trustedPins[0]
|
||||
p.pinChecker.trustedPins[0] = ""
|
||||
|
||||
client := cm.GetClient("pmapi" + t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 0, *called)
|
||||
}
|
||||
|
||||
func _TestTLSPinNoMatch(t *testing.T) { // nolint[unused]
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
cm.host = liveAPI
|
||||
|
||||
called, p := createAndSetPinningDialer(cm)
|
||||
for i := 0; i < len(p.pinChecker.trustedPins); i++ {
|
||||
p.pinChecker.trustedPins[i] = "testing"
|
||||
}
|
||||
|
||||
client := cm.GetClient("pmapi" + t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
// check that it will be called only once per session
|
||||
client = cm.GetClient("pmapi" + t.Name())
|
||||
_, err = client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 1, *called)
|
||||
}
|
||||
|
||||
func _TestTLSPinInvalid(t *testing.T) { // nolint[unused]
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
|
||||
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONResponsefromFile(t, w, "/auth/info/post_response.json", 0)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
called, _ := createAndSetPinningDialer(cm)
|
||||
|
||||
client := cm.GetClient("pmapi" + t.Name())
|
||||
|
||||
cm.host = liveAPI
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
cm.host = ts.URL
|
||||
_, err = client.AuthInfo("this.address.is.disabled")
|
||||
Assert(t, err != nil, "error is expected but have %v", err)
|
||||
|
||||
Equals(t, 1, *called)
|
||||
}
|
||||
|
||||
// The tests below should pass but cannot run in CI due to proxy issues.
|
||||
|
||||
func _TestTLSSignedCertWrongPublicKey(t *testing.T) { // nolint[unused]
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
_, dialer := createAndSetPinningDialer(cm)
|
||||
_, err := dialer.DialTLS("tcp", "rsa4096.badssl.com:443")
|
||||
assert.Error(t, err, "expected dial to fail because of wrong public key")
|
||||
}
|
||||
|
||||
func _TestTLSSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
_, dialer := createAndSetPinningDialer(cm)
|
||||
dialer.pinChecker.trustedPins = append(dialer.pinChecker.trustedPins, `pin-sha256="W8/42Z0ffufwnHIOSndT+eVzBJSC0E8uTIC8O6mEliQ="`)
|
||||
_, err := dialer.DialTLS("tcp", "rsa4096.badssl.com:443")
|
||||
assert.NoError(t, err, "expected dial to succeed because public key is known and cert is signed by CA")
|
||||
}
|
||||
|
||||
func _TestTLSSelfSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
|
||||
cm := newTestClientManager(testLiveConfig)
|
||||
_, dialer := createAndSetPinningDialer(cm)
|
||||
dialer.pinChecker.trustedPins = append(dialer.pinChecker.trustedPins, `pin-sha256="9SLklscvzMYj8f+52lp5ze/hY0CFHyLSPQzSpYYIBm8="`)
|
||||
_, err := dialer.DialTLS("tcp", "self-signed.badssl.com:443")
|
||||
assert.NoError(t, err, "expected dial to succeed because public key is known despite cert being self-signed")
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// ProxyTLSDialer wraps a TLSDialer to switch to a proxy if the initial dial fails.
|
||||
type ProxyTLSDialer struct {
|
||||
dialer TLSDialer
|
||||
|
||||
cm *ClientManager
|
||||
}
|
||||
|
||||
// NewProxyTLSDialer constructs a dialer which provides a proxy-managing layer on top of an underlying dialer.
|
||||
func NewProxyTLSDialer(dialer TLSDialer, cm *ClientManager) *ProxyTLSDialer {
|
||||
return &ProxyTLSDialer{
|
||||
dialer: dialer,
|
||||
cm: cm,
|
||||
}
|
||||
}
|
||||
|
||||
// DialTLS dials the given network/address. If it fails, it retries using a proxy.
|
||||
func (d *ProxyTLSDialer) DialTLS(network, address string) (conn net.Conn, err error) {
|
||||
if conn, err = d.dialer.DialTLS(network, address); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if !d.cm.allowProxy {
|
||||
return
|
||||
}
|
||||
|
||||
var proxy string
|
||||
|
||||
if proxy, err = d.cm.switchToReachableServer(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return d.dialer.DialTLS(network, net.JoinHostPort(proxy, port))
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
)
|
||||
|
||||
// DownloadAndVerify downloads a file and its signature from the given locations `file` and `sig`.
|
||||
// The file and its signature are verified using the given keyring `kr`.
|
||||
// If the file is verified successfully, it can be read from the returned reader.
|
||||
// TLS fingerprinting is used to verify that connections are only made to known servers.
|
||||
func (c *client) DownloadAndVerify(file, sig string, kr *crypto.KeyRing) (io.Reader, error) {
|
||||
var fb, sb []byte
|
||||
|
||||
if err := c.fetchFile(file, func(r io.Reader) (err error) {
|
||||
fb, err = ioutil.ReadAll(r)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.fetchFile(sig, func(r io.Reader) (err error) {
|
||||
sb, err = ioutil.ReadAll(r)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := kr.VerifyDetached(
|
||||
crypto.NewPlainMessage(fb),
|
||||
crypto.NewPGPSignature(sb),
|
||||
crypto.GetUnixTime(),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(fb), nil
|
||||
}
|
||||
|
||||
func (c *client) fetchFile(file string, fn func(io.Reader) error) error {
|
||||
res, err := c.hc.Get(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to get file: http error %v", res.StatusCode)
|
||||
}
|
||||
|
||||
return fn(res.Body)
|
||||
}
|
||||
9
pkg/pmapi/errors.go
Normal file
9
pkg/pmapi/errors.go
Normal file
@ -0,0 +1,9 @@
|
||||
package pmapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoConnection = errors.New("no internet connection")
|
||||
ErrAPIFailure = errors.New("API returned an error")
|
||||
ErrUnauthorized = errors.New("API client is unauthorized")
|
||||
)
|
||||
@ -18,9 +18,11 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Event represents changes since the last check.
|
||||
@ -137,7 +139,7 @@ type EventMessageUpdated struct {
|
||||
ID string
|
||||
|
||||
Subject *string
|
||||
Unread *int
|
||||
Unread *Boolean
|
||||
Flags *int64
|
||||
Sender *mail.Address
|
||||
ToList *[]*mail.Address
|
||||
@ -163,62 +165,28 @@ type EventAddress struct {
|
||||
Address *Address
|
||||
}
|
||||
|
||||
type EventRes struct {
|
||||
Res
|
||||
*Event
|
||||
}
|
||||
|
||||
type LatestEventRes struct {
|
||||
Res
|
||||
*Event
|
||||
}
|
||||
|
||||
// GetEvent returns a summary of events that occurred since last. To get the latest event,
|
||||
// provide an empty last value. The latest event is always empty.
|
||||
func (c *client) GetEvent(last string) (event *Event, err error) {
|
||||
return c.getEvent(last, 1)
|
||||
}
|
||||
|
||||
func (c *client) getEvent(last string, numberOfMergedEvents int) (event *Event, err error) {
|
||||
var req *http.Request
|
||||
if last == "" {
|
||||
req, err = c.NewRequest("GET", "/events/latest", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res LatestEventRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
event, err = res.Event, res.Err()
|
||||
} else {
|
||||
req, err = c.NewRequest("GET", "/events/"+last, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res EventRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
event, err = res.Event, res.Err()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if event.More == 1 && numberOfMergedEvents < maxNumberOfMergedEvents {
|
||||
var moreEvents *Event
|
||||
if moreEvents, err = c.getEvent(event.EventID, numberOfMergedEvents+1); err != nil {
|
||||
return
|
||||
}
|
||||
event = mergeEvents(event, moreEvents)
|
||||
}
|
||||
func (c *client) GetEvent(ctx context.Context, eventID string) (event *Event, err error) {
|
||||
if eventID == "" {
|
||||
eventID = "latest"
|
||||
}
|
||||
|
||||
return event, err
|
||||
var res struct {
|
||||
*Event
|
||||
|
||||
More int
|
||||
}
|
||||
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/events/" + eventID)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FIXME(conman): use mergeEvents() function.
|
||||
|
||||
return res.Event, nil
|
||||
}
|
||||
|
||||
// mergeEvents combines an old events and a new events object.
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@ -31,32 +32,41 @@ import (
|
||||
)
|
||||
|
||||
func TestClient_GetEvent(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/latest"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testEventBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("")
|
||||
event, err := c.GetEvent(context.TODO(), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testEvent, event)
|
||||
}
|
||||
|
||||
func TestClient_GetEvent_withID(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/"+testEvent.EventID))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testEventBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent(testEvent.EventID)
|
||||
event, err := c.GetEvent(context.TODO(), testEvent.EventID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testEvent, event)
|
||||
}
|
||||
|
||||
// We first call GetEvent with id of eventID1, which returns More=1 so we fetch with id eventID2.
|
||||
func TestClient_GetEvent_mergeEvents(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// FIXME(conman): Merging is currently not supported. Implement it and then enable this test again!
|
||||
func _TestClient_GetEvent_mergeEvents(t *testing.T) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.RequestURI() {
|
||||
case "/events/eventID1":
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID1"))
|
||||
@ -70,15 +80,16 @@ func TestClient_GetEvent_mergeEvents(t *testing.T) {
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("eventID1")
|
||||
event, err := c.GetEvent(context.TODO(), "eventID1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testEventMerged, event)
|
||||
}
|
||||
|
||||
func TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) {
|
||||
// FIXME(conman): Merging is currently not supported. Implement it and then enable this test again!
|
||||
func _TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) {
|
||||
numberOfCalls := 0
|
||||
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numberOfCalls++
|
||||
|
||||
re := regexp.MustCompile(`/eventID([0-9]+)`)
|
||||
@ -93,18 +104,20 @@ func TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) {
|
||||
fmt.Println("")
|
||||
|
||||
body := strings.ReplaceAll(testEventBodyMore1, "eventID2", "eventID"+strconv.Itoa(eventID+1))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("eventID1")
|
||||
event, err := c.GetEvent(context.TODO(), "eventID1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, maxNumberOfMergedEvents, numberOfCalls)
|
||||
require.Equal(t, 1, event.More)
|
||||
}
|
||||
|
||||
var (
|
||||
testEventMessageUpdateUnread = 0
|
||||
testEventMessageUpdateUnread = False
|
||||
|
||||
testEvent = &Event{
|
||||
EventID: "eventID1",
|
||||
|
||||
@ -18,110 +18,68 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Import errors.
|
||||
const (
|
||||
ImportMessageTooLarge = 36022
|
||||
)
|
||||
const MaxImportMessageRequestLength = 10
|
||||
|
||||
// ImportReq is an import request.
|
||||
type ImportReq struct {
|
||||
// A list of messages that will be imported.
|
||||
Messages []*ImportMsgReq
|
||||
}
|
||||
|
||||
// WriteTo writes the import request to a multipart writer.
|
||||
func (req *ImportReq) WriteTo(w *multipart.Writer) (err error) {
|
||||
// Create Metadata field.
|
||||
mw, err := w.CreateFormField("Metadata")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build metadata.
|
||||
metadata := map[string]*ImportMsgReq{}
|
||||
for i, msg := range req.Messages {
|
||||
name := strconv.Itoa(i)
|
||||
metadata[name] = msg
|
||||
}
|
||||
|
||||
// Write metadata.
|
||||
if err = json.NewEncoder(mw).Encode(metadata); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write messages.
|
||||
for i, msg := range req.Messages {
|
||||
name := strconv.Itoa(i)
|
||||
|
||||
var fw io.Writer
|
||||
if fw, err = w.CreateFormFile(name, name+".eml"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = fw.Write(msg.Body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Adding new line to properly fetch the whole body on the API side.
|
||||
// The reason is the bug in PHP: https://bugs.php.net/bug.php?id=75923
|
||||
// Messages generated by PM already have it but importing already
|
||||
// encrypted messages might not have it.
|
||||
if _, err = fw.Write([]byte("\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ImportMsgReq is a request to import a message. All fields are optional except AddressID and Body.
|
||||
type ImportMsgReq struct {
|
||||
// The address where the message will be imported.
|
||||
AddressID string
|
||||
// The full MIME message.
|
||||
Body []byte `json:"-"`
|
||||
|
||||
// 0: read, 1: unread.
|
||||
Unread int
|
||||
// 1 if the message has been replied.
|
||||
IsReplied int
|
||||
// 1 if the message has been replied to all.
|
||||
IsRepliedAll int
|
||||
// 1 if the message has been forwarded.
|
||||
IsForwarded int
|
||||
// The time when the message was received as a Unix time.
|
||||
Time int64
|
||||
// The type of the imported message.
|
||||
Flags int64
|
||||
// The labels to apply to the imported message. Must contain at least one system label.
|
||||
LabelIDs []string
|
||||
Metadata *ImportMetadata // Metadata about the message to import.
|
||||
Message []byte // The raw RFC822 message.
|
||||
}
|
||||
|
||||
func (req ImportMsgReq) String() string {
|
||||
data, _ := json.Marshal(req)
|
||||
return string(data)
|
||||
}
|
||||
type ImportMsgReqs []*ImportMsgReq
|
||||
|
||||
// ImportRes is a response to an import request.
|
||||
type ImportRes struct {
|
||||
Res
|
||||
func (reqs ImportMsgReqs) buildMultipartFormData() ([]*resty.MultipartField, error) {
|
||||
var fields []*resty.MultipartField
|
||||
|
||||
Responses []struct {
|
||||
Name string
|
||||
Response struct {
|
||||
Res
|
||||
MessageID string
|
||||
}
|
||||
metadata := make(map[string]*ImportMetadata)
|
||||
|
||||
for i, req := range reqs {
|
||||
name := strconv.Itoa(i)
|
||||
|
||||
metadata[name] = req.Metadata
|
||||
|
||||
fields = append(fields, &resty.MultipartField{
|
||||
Param: name,
|
||||
FileName: name + ".eml",
|
||||
ContentType: "message/rfc822",
|
||||
Reader: bytes.NewReader(req.Message),
|
||||
})
|
||||
}
|
||||
|
||||
b, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields = append(fields, &resty.MultipartField{
|
||||
Param: "Metadata",
|
||||
ContentType: "application/json",
|
||||
Reader: bytes.NewReader(b),
|
||||
})
|
||||
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// TODO: Add other metadata.
|
||||
type ImportMetadata struct {
|
||||
AddressID string
|
||||
Unread Boolean // 0: read, 1: unread.
|
||||
IsReplied Boolean // 1 if the message has been replied.
|
||||
IsRepliedAll Boolean // 1 if the message has been replied to all.
|
||||
IsForwarded Boolean // 1 if the message has been forwarded.
|
||||
Time int64 // The time when the message was received as a Unix time.
|
||||
Flags int64 // The type of the imported message.
|
||||
LabelIDs []string // The labels to apply to the imported message. Must contain at least one system label.
|
||||
}
|
||||
|
||||
// ImportMsgRes is a response to a single message import request.
|
||||
type ImportMsgRes struct {
|
||||
// The error encountered while importing the message, if any.
|
||||
Error error
|
||||
@ -130,41 +88,46 @@ type ImportMsgRes struct {
|
||||
}
|
||||
|
||||
// Import imports messages to the user's account.
|
||||
func (c *client) Import(reqs []*ImportMsgReq) (resps []*ImportMsgRes, err error) {
|
||||
importReq := &ImportReq{Messages: reqs}
|
||||
func (c *client) Import(ctx context.Context, reqs ImportMsgReqs) ([]*ImportMsgRes, error) {
|
||||
if len(reqs) > MaxImportMessageRequestLength {
|
||||
return nil, errors.New("request is too long")
|
||||
}
|
||||
|
||||
req, w, err := c.NewMultipartRequest("POST", "/mail/v4/messages/import")
|
||||
fields, err := reqs.buildMultipartFormData()
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We will write the request as long as it is sent to the API.
|
||||
var importRes ImportRes
|
||||
done := make(chan error, 1)
|
||||
go (func() {
|
||||
done <- c.DoJSON(req, &importRes)
|
||||
})()
|
||||
|
||||
// Write the request.
|
||||
if err = importReq.WriteTo(w.Writer); err != nil {
|
||||
return
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
if err = <-done; err != nil {
|
||||
return
|
||||
}
|
||||
if err = importRes.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resps = make([]*ImportMsgRes, len(importRes.Responses))
|
||||
for i, r := range importRes.Responses {
|
||||
resps[i] = &ImportMsgRes{
|
||||
Error: r.Response.Err(),
|
||||
MessageID: r.Response.MessageID,
|
||||
var res struct {
|
||||
Responses []struct {
|
||||
Name string
|
||||
Response struct {
|
||||
Error
|
||||
MessageID string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resps, err
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetMultipartFields(fields...).SetResult(&res).Post("/mail/v4/messages/import")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resps []*ImportMsgRes
|
||||
|
||||
for _, resp := range res.Responses {
|
||||
var err error
|
||||
|
||||
if resp.Response.Code != 1000 {
|
||||
err = resp.Response.Error
|
||||
}
|
||||
|
||||
resps = append(resps, &ImportMsgRes{
|
||||
Error: err,
|
||||
MessageID: resp.Response.MessageID,
|
||||
})
|
||||
}
|
||||
|
||||
return resps, nil
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -32,11 +33,13 @@ import (
|
||||
|
||||
var testImportReqs = []*ImportMsgReq{
|
||||
{
|
||||
AddressID: "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
|
||||
Body: []byte("Hello World!"),
|
||||
Unread: 0,
|
||||
Flags: FlagReceived | FlagImported,
|
||||
LabelIDs: []string{ArchiveLabel},
|
||||
Metadata: &ImportMetadata{
|
||||
AddressID: "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
|
||||
Unread: 0,
|
||||
Flags: FlagReceived | FlagImported,
|
||||
LabelIDs: []string{ArchiveLabel},
|
||||
},
|
||||
Message: []byte("Hello World!"),
|
||||
},
|
||||
}
|
||||
|
||||
@ -54,7 +57,7 @@ var testImportRes = &ImportMsgRes{
|
||||
}
|
||||
|
||||
func TestClient_Import(t *testing.T) { // nolint[funlen]
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/mail/v4/messages/import"))
|
||||
|
||||
contentType, params, err := pmmime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@ -67,51 +70,13 @@ func TestClient_Import(t *testing.T) { // nolint[funlen]
|
||||
|
||||
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||
|
||||
// First part is metadata.
|
||||
// First part is message body.
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
t.Error("Expected no error while reading first part of request body, got:", err)
|
||||
}
|
||||
|
||||
contentDisp, params, err := pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing part content disposition, got:", err)
|
||||
}
|
||||
if contentDisp != "form-data" {
|
||||
t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType)
|
||||
}
|
||||
if params["name"] != "Metadata" {
|
||||
t.Errorf("Invalid part name: expected %v but got %v", "Metadata", params["name"])
|
||||
}
|
||||
|
||||
metadata := map[string]*ImportMsgReq{}
|
||||
if err := json.NewDecoder(p).Decode(&metadata); err != nil {
|
||||
t.Error("Expected no error while parsing metadata json, got:", err)
|
||||
}
|
||||
|
||||
if len(metadata) != 1 {
|
||||
t.Errorf("Expected metadata to contain exactly one item, got %v", metadata)
|
||||
}
|
||||
|
||||
req := metadata["0"]
|
||||
if metadata["0"] == nil {
|
||||
t.Errorf("Expected metadata to contain one item indexed by 0, got %v", metadata)
|
||||
}
|
||||
|
||||
// No Body in metadata.
|
||||
expected := *testImportReqs[0]
|
||||
expected.Body = nil
|
||||
if !reflect.DeepEqual(&expected, req) {
|
||||
t.Errorf("Invalid message metadata: expected %v, got %v", &expected, req)
|
||||
}
|
||||
|
||||
// Second part is message body.
|
||||
p, err = mr.NextPart()
|
||||
if err != nil {
|
||||
t.Error("Expected no error while reading second part of request body, got:", err)
|
||||
}
|
||||
|
||||
contentDisp, params, err = pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
|
||||
contentDisp, params, err := pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing part content disposition, got:", err)
|
||||
}
|
||||
@ -127,8 +92,44 @@ func TestClient_Import(t *testing.T) { // nolint[funlen]
|
||||
t.Error("Expected no error while reading second part body, got:", err)
|
||||
}
|
||||
|
||||
if string(b) != string(testImportReqs[0].Body)+"\r\n" {
|
||||
t.Errorf("Invalid message body: expected %v but got %v", string(testImportReqs[0].Body), string(b))
|
||||
if string(b) != string(testImportReqs[0].Message) {
|
||||
t.Errorf("Invalid message body: expected %v but got %v", string(testImportReqs[0].Message), string(b))
|
||||
}
|
||||
|
||||
// Second part is metadata.
|
||||
p, err = mr.NextPart()
|
||||
if err != nil {
|
||||
t.Error("Expected no error while reading first part of request body, got:", err)
|
||||
}
|
||||
|
||||
contentDisp, params, err = pmmime.ParseMediaType(p.Header.Get("Content-Disposition"))
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing part content disposition, got:", err)
|
||||
}
|
||||
if contentDisp != "form-data" {
|
||||
t.Errorf("Invalid part content disposition: expected %v but got %v", "form-data", contentType)
|
||||
}
|
||||
if params["name"] != "Metadata" {
|
||||
t.Errorf("Invalid part name: expected %v but got %v", "Metadata", params["name"])
|
||||
}
|
||||
|
||||
metadata := map[string]*ImportMetadata{}
|
||||
if err := json.NewDecoder(p).Decode(&metadata); err != nil {
|
||||
t.Error("Expected no error while parsing metadata json, got:", err)
|
||||
}
|
||||
|
||||
if len(metadata) != 1 {
|
||||
t.Errorf("Expected metadata to contain exactly one item, got %v", metadata)
|
||||
}
|
||||
|
||||
req := metadata["0"]
|
||||
if metadata["0"] == nil {
|
||||
t.Errorf("Expected metadata to contain one item indexed by 0, got %v", metadata)
|
||||
}
|
||||
|
||||
expected := *testImportReqs[0].Metadata
|
||||
if !reflect.DeepEqual(&expected, req) {
|
||||
t.Errorf("Invalid message metadata: expected %v, got %v", &expected, req)
|
||||
}
|
||||
|
||||
// No more parts.
|
||||
@ -137,11 +138,13 @@ func TestClient_Import(t *testing.T) { // nolint[funlen]
|
||||
t.Error("Expected no more parts but error was not EOF, got:", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testImportBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
imported, err := c.Import(testImportReqs)
|
||||
imported, err := c.Import(context.TODO(), testImportReqs)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while importing, got:", err)
|
||||
}
|
||||
|
||||
103
pkg/pmapi/key.go
103
pkg/pmapi/key.go
@ -18,11 +18,10 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Key flags.
|
||||
@ -31,84 +30,34 @@ const (
|
||||
UseToEncryptFlag
|
||||
)
|
||||
|
||||
type PublicKeyRes struct {
|
||||
Res
|
||||
|
||||
RecipientType int
|
||||
MIMEType string
|
||||
Keys []PublicKey
|
||||
}
|
||||
|
||||
type PublicKey struct {
|
||||
Flags int
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
// PublicKeys returns the public keys of the given email addresses.
|
||||
func (c *client) PublicKeys(emails []string) (keys map[string]*crypto.Key, err error) {
|
||||
if len(emails) == 0 {
|
||||
err = fmt.Errorf("pmapi: cannot get public keys: no email address provided")
|
||||
return
|
||||
}
|
||||
|
||||
keys = make(map[string]*crypto.Key)
|
||||
|
||||
for _, email := range emails {
|
||||
email = url.QueryEscape(email)
|
||||
|
||||
var req *http.Request
|
||||
if req, err = c.NewRequest("GET", "/keys?Email="+email, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res PublicKeyRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rawKey := range res.Keys {
|
||||
if rawKey.Flags&UseToEncryptFlag == UseToEncryptFlag {
|
||||
var key *crypto.Key
|
||||
|
||||
if key, err = crypto.NewKeyFromArmored(rawKey.PublicKey); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
keys[email] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys, err
|
||||
}
|
||||
type RecipientType int
|
||||
|
||||
const (
|
||||
RecipientInternal = 1
|
||||
RecipientExternal = 2
|
||||
RecipientTypeInternal RecipientType = iota + 1
|
||||
RecipientTypeExternal
|
||||
)
|
||||
|
||||
// GetPublicKeysForEmail returns all sending public keys for the given email address.
|
||||
func (c *client) GetPublicKeysForEmail(email string) (keys []PublicKey, internal bool, err error) {
|
||||
func (c *client) GetPublicKeysForEmail(ctx context.Context, email string) (keys []PublicKey, internal bool, err error) {
|
||||
email = url.QueryEscape(email)
|
||||
|
||||
var req *http.Request
|
||||
if req, err = c.NewRequest("GET", "/keys?Email="+email, nil); err != nil {
|
||||
return
|
||||
var res struct {
|
||||
Keys []PublicKey
|
||||
RecipientType RecipientType
|
||||
}
|
||||
|
||||
var res PublicKeyRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).SetQueryParam("Email", email).Get("/keys")
|
||||
}); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
internal = res.RecipientType == RecipientInternal
|
||||
|
||||
for _, key := range res.Keys {
|
||||
if key.Flags&UseToEncryptFlag == UseToEncryptFlag {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return
|
||||
return res.Keys, res.RecipientType == RecipientTypeInternal, nil
|
||||
}
|
||||
|
||||
// KeySalt contains id and salt for key.
|
||||
@ -116,25 +65,17 @@ type KeySalt struct {
|
||||
ID, KeySalt string
|
||||
}
|
||||
|
||||
// KeySaltRes is used to unmarshal API response.
|
||||
type KeySaltRes struct {
|
||||
Res
|
||||
KeySalts []KeySalt
|
||||
}
|
||||
|
||||
// GetKeySalts sends request to get list of key salts (n.b. locked route).
|
||||
func (c *client) GetKeySalts() (keySalts []KeySalt, err error) {
|
||||
var req *http.Request
|
||||
if req, err = c.NewRequest("GET", "/keys/salts", nil); err != nil {
|
||||
return
|
||||
func (c *client) GetKeySalts(ctx context.Context) (keySalts []KeySalt, err error) {
|
||||
var res struct {
|
||||
KeySalts []KeySalt
|
||||
}
|
||||
|
||||
var res KeySaltRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/keys/salts")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keySalts = res.KeySalts
|
||||
|
||||
return
|
||||
return res.KeySalts, nil
|
||||
}
|
||||
|
||||
@ -18,8 +18,11 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// System labels.
|
||||
@ -92,100 +95,84 @@ type Label struct {
|
||||
Notify int
|
||||
}
|
||||
|
||||
type LabelListRes struct {
|
||||
Res
|
||||
Labels []*Label
|
||||
func (c *client) ListLabels(ctx context.Context) (labels []*Label, err error) {
|
||||
return c.ListLabelType(ctx, LabelTypeMailbox)
|
||||
}
|
||||
|
||||
func (c *client) ListLabels() (labels []*Label, err error) {
|
||||
return c.ListLabelType(LabelTypeMailbox)
|
||||
}
|
||||
|
||||
func (c *client) ListContactGroups() (labels []*Label, err error) {
|
||||
return c.ListLabelType(LabelTypeContactGroup)
|
||||
func (c *client) ListContactGroups(ctx context.Context) (labels []*Label, err error) {
|
||||
return c.ListLabelType(ctx, LabelTypeContactGroup)
|
||||
}
|
||||
|
||||
// ListLabelType lists all labels created by the user.
|
||||
func (c *client) ListLabelType(labelType int) (labels []*Label, err error) {
|
||||
req, err := c.NewRequest("GET", fmt.Sprintf("/labels?%d", labelType), nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) ListLabelType(ctx context.Context, labelType int) (labels []*Label, err error) {
|
||||
var res struct {
|
||||
Labels []*Label
|
||||
}
|
||||
|
||||
var res LabelListRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetQueryParam("Type", strconv.Itoa(labelType)).SetResult(&res).Get("/v4/labels")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels, err = res.Labels, res.Err()
|
||||
return
|
||||
return res.Labels, nil
|
||||
}
|
||||
|
||||
type LabelReq struct {
|
||||
*Label
|
||||
}
|
||||
|
||||
type LabelRes struct {
|
||||
Res
|
||||
Label *Label
|
||||
}
|
||||
|
||||
// CreateLabel creates a new label.
|
||||
func (c *client) CreateLabel(label *Label) (created *Label, err error) {
|
||||
func (c *client) CreateLabel(ctx context.Context, label *Label) (created *Label, err error) {
|
||||
if label.Name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
|
||||
labelReq := &LabelReq{label}
|
||||
req, err := c.NewJSONRequest("POST", "/labels", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
var res struct {
|
||||
Label *Label
|
||||
}
|
||||
|
||||
var res LabelRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(&LabelReq{
|
||||
Label: label,
|
||||
}).SetResult(&res).Post("/v4/labels")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err = res.Label, res.Err()
|
||||
return
|
||||
return res.Label, nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates a label.
|
||||
func (c *client) UpdateLabel(label *Label) (updated *Label, err error) {
|
||||
func (c *client) UpdateLabel(ctx context.Context, label *Label) (updated *Label, err error) {
|
||||
if label.Name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
|
||||
labelReq := &LabelReq{label}
|
||||
req, err := c.NewJSONRequest("PUT", "/labels/"+label.ID, labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
var res struct {
|
||||
Label *Label
|
||||
}
|
||||
|
||||
var res LabelRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(&LabelReq{
|
||||
Label: label,
|
||||
}).SetResult(&res).Put("/v4/labels/" + label.ID)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err = res.Label, res.Err()
|
||||
return
|
||||
return res.Label, nil
|
||||
}
|
||||
|
||||
// DeleteLabel deletes a label.
|
||||
func (c *client) DeleteLabel(id string) (err error) {
|
||||
req, err := c.NewRequest("DELETE", "/labels/"+id, nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) DeleteLabel(ctx context.Context, labelID string) error {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.Delete("/v4/labels/" + labelID)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeastUsedColor is intended to return color for creating a new inbox or label.
|
||||
|
||||
@ -19,6 +19,7 @@ package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -90,14 +91,16 @@ const testDeleteLabelBody = `{
|
||||
`
|
||||
|
||||
func TestClient_ListLabels(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/labels?1"))
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/v4/labels?Type=1"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testLabelsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
labels, err := c.ListLabels()
|
||||
labels, err := c.ListLabels(context.TODO())
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while listing labels, got:", err)
|
||||
}
|
||||
@ -114,8 +117,8 @@ func TestClient_ListLabels(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_CreateLabel(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/labels"))
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/v4/labels"))
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
_, err := body.ReadFrom(r.Body)
|
||||
@ -133,11 +136,13 @@ func TestClient_CreateLabel(t *testing.T) {
|
||||
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelReq.Label, labelReq.Label)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testCreateLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
created, err := c.CreateLabel(testLabelReq.Label)
|
||||
created, err := c.CreateLabel(context.TODO(), testLabelReq.Label)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while creating label, got:", err)
|
||||
}
|
||||
@ -148,18 +153,18 @@ func TestClient_CreateLabel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_CreateEmptyLabel(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
r.Fail(t, "API should not be called")
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
_, err := c.CreateLabel(&Label{})
|
||||
_, err := c.CreateLabel(context.TODO(), &Label{})
|
||||
r.EqualError(t, err, "name is required")
|
||||
}
|
||||
|
||||
func TestClient_UpdateLabel(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "PUT", "/labels/"+testLabelCreated.ID))
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "PUT", "/v4/labels/"+testLabelCreated.ID))
|
||||
|
||||
var labelReq LabelReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&labelReq); err != nil {
|
||||
@ -169,11 +174,13 @@ func TestClient_UpdateLabel(t *testing.T) {
|
||||
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelCreated, labelReq.Label)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testCreateLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
updated, err := c.UpdateLabel(testLabelCreated)
|
||||
updated, err := c.UpdateLabel(context.TODO(), testLabelCreated)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while updating label, got:", err)
|
||||
}
|
||||
@ -184,24 +191,26 @@ func TestClient_UpdateLabel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClient_UpdateLabelToEmptyName(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
s, c := newTestClient(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
r.Fail(t, "API should not be called")
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
_, err := c.UpdateLabel(&Label{ID: "label"})
|
||||
_, err := c.UpdateLabel(context.TODO(), &Label{ID: "label"})
|
||||
r.EqualError(t, err, "name is required")
|
||||
}
|
||||
|
||||
func TestClient_DeleteLabel(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/labels/"+testLabelCreated.ID))
|
||||
s, c := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/v4/labels/"+testLabelCreated.ID))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testDeleteLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.DeleteLabel(testLabelCreated.ID)
|
||||
err := c.DeleteLabel(context.TODO(), testLabelCreated.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while deleting label, got:", err)
|
||||
}
|
||||
|
||||
127
pkg/pmapi/manager.go
Normal file
127
pkg/pmapi/manager.go
Normal file
@ -0,0 +1,127 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type manager struct {
|
||||
rc *resty.Client
|
||||
|
||||
isDown bool
|
||||
locker sync.Locker
|
||||
observers []ConnectionObserver
|
||||
}
|
||||
|
||||
func newManager(cfg Config) *manager {
|
||||
m := &manager{
|
||||
rc: resty.New(),
|
||||
locker: &sync.Mutex{},
|
||||
}
|
||||
|
||||
// Set the API host.
|
||||
m.rc.SetHostURL(cfg.HostURL)
|
||||
|
||||
// Set static header values.
|
||||
m.rc.SetHeader("x-pm-appversion", cfg.AppVersion)
|
||||
|
||||
// Set middleware.
|
||||
m.rc.OnAfterResponse(catchAPIError)
|
||||
|
||||
// Configure retry mechanism.
|
||||
m.rc.SetRetryMaxWaitTime(time.Minute)
|
||||
m.rc.SetRetryAfter(catchRetryAfter)
|
||||
m.rc.AddRetryCondition(catchTooManyRequests)
|
||||
m.rc.AddRetryCondition(catchNoResponse)
|
||||
m.rc.AddRetryCondition(catchProxyAvailable)
|
||||
|
||||
// Determine what happens when requests succeed/fail.
|
||||
m.rc.OnAfterResponse(m.handleRequestSuccess)
|
||||
m.rc.OnError(m.handleRequestFailure)
|
||||
|
||||
// Set the data type of API errors.
|
||||
m.rc.SetError(&Error{})
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func New(cfg Config) Manager {
|
||||
return newManager(cfg)
|
||||
}
|
||||
|
||||
func (m *manager) SetLogger(logger resty.Logger) {
|
||||
m.rc.SetLogger(logger)
|
||||
m.rc.SetDebug(true)
|
||||
}
|
||||
|
||||
func (m *manager) SetTransport(transport http.RoundTripper) {
|
||||
m.rc.SetTransport(transport)
|
||||
}
|
||||
|
||||
func (m *manager) SetCookieJar(jar http.CookieJar) {
|
||||
m.rc.SetCookieJar(jar)
|
||||
}
|
||||
|
||||
func (m *manager) SetRetryCount(count int) {
|
||||
m.rc.SetRetryCount(count)
|
||||
}
|
||||
|
||||
func (m *manager) AddConnectionObserver(observer ConnectionObserver) {
|
||||
m.observers = append(m.observers, observer)
|
||||
}
|
||||
|
||||
func (m *manager) r(ctx context.Context) *resty.Request {
|
||||
return m.rc.R().SetContext(ctx)
|
||||
}
|
||||
|
||||
func (m *manager) handleRequestSuccess(_ *resty.Client, res *resty.Response) error {
|
||||
m.locker.Lock()
|
||||
defer m.locker.Unlock()
|
||||
|
||||
if !m.isDown {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We successfully got a response; connection must be up.
|
||||
|
||||
m.isDown = false
|
||||
|
||||
for _, observer := range m.observers {
|
||||
observer.OnUp()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) handleRequestFailure(req *resty.Request, err error) {
|
||||
m.locker.Lock()
|
||||
defer m.locker.Unlock()
|
||||
|
||||
if m.isDown {
|
||||
return
|
||||
}
|
||||
|
||||
if res, ok := err.(*resty.ResponseError); ok && res.Response.RawResponse != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We didn't get any response; connection must be down.
|
||||
|
||||
m.isDown = true
|
||||
|
||||
for _, observer := range m.observers {
|
||||
observer.OnDown()
|
||||
}
|
||||
|
||||
go m.pingUntilSuccess()
|
||||
}
|
||||
|
||||
func (m *manager) pingUntilSuccess() {
|
||||
for m.testPing(context.Background()) != nil {
|
||||
time.Sleep(time.Second) // TODO: How long to sleep here?
|
||||
}
|
||||
}
|
||||
114
pkg/pmapi/manager_auth.go
Normal file
114
pkg/pmapi/manager_auth.go
Normal file
@ -0,0 +1,114 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
)
|
||||
|
||||
func (m *manager) NewClient(uid, acc, ref string, exp time.Time) Client {
|
||||
return newClient(m, uid).withAuth(acc, ref, exp)
|
||||
}
|
||||
|
||||
func (m *manager) NewClientWithRefresh(ctx context.Context, uid, ref string) (Client, *Auth, error) {
|
||||
c := newClient(m, uid)
|
||||
|
||||
auth, err := m.authRefresh(ctx, uid, ref)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.withAuth(auth.AccessToken, auth.RefreshToken, expiresIn(auth.ExpiresIn)), auth, nil
|
||||
}
|
||||
|
||||
func (m *manager) NewClientWithLogin(ctx context.Context, username, password string) (Client, *Auth, error) {
|
||||
info, err := m.getAuthInfo(ctx, GetAuthInfoReq{Username: username})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
srpAuth, err := srp.NewSrpAuth(info.Version, username, password, info.Salt, info.Modulus, info.ServerEphemeral)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
proofs, err := srpAuth.GenerateSrpProofs(2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
auth, err := m.auth(ctx, AuthReq{
|
||||
Username: username,
|
||||
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
|
||||
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
|
||||
SRPSession: info.SRPSession,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return newClient(m, auth.UID).withAuth(auth.AccessToken, auth.RefreshToken, expiresIn(auth.ExpiresIn)), auth, nil
|
||||
}
|
||||
|
||||
func (m *manager) getAuthModulus(ctx context.Context) (AuthModulus, error) {
|
||||
var res struct {
|
||||
AuthModulus
|
||||
}
|
||||
|
||||
if _, err := m.r(ctx).SetResult(&res).Get("/auth/modulus"); err != nil {
|
||||
return AuthModulus{}, err
|
||||
}
|
||||
|
||||
return res.AuthModulus, nil
|
||||
}
|
||||
|
||||
func (m *manager) getAuthInfo(ctx context.Context, req GetAuthInfoReq) (*AuthInfo, error) {
|
||||
var res struct {
|
||||
*AuthInfo
|
||||
}
|
||||
|
||||
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/auth/info"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.AuthInfo, nil
|
||||
}
|
||||
|
||||
func (m *manager) auth(ctx context.Context, req AuthReq) (*Auth, error) {
|
||||
var res struct {
|
||||
*Auth
|
||||
}
|
||||
|
||||
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/auth"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Auth, nil
|
||||
}
|
||||
|
||||
func (m *manager) authRefresh(ctx context.Context, uid, ref string) (*Auth, error) {
|
||||
var req = AuthRefreshReq{
|
||||
UID: uid,
|
||||
RefreshToken: ref,
|
||||
ResponseType: "token",
|
||||
GrantType: "refresh_token",
|
||||
RedirectURI: "https://protonmail.ch",
|
||||
State: randomString(32),
|
||||
}
|
||||
|
||||
var res struct {
|
||||
*Auth
|
||||
}
|
||||
|
||||
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/auth/refresh"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Auth, nil
|
||||
}
|
||||
|
||||
func expiresIn(seconds int64) time.Time {
|
||||
return time.Now().Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
51
pkg/pmapi/manager_download.go
Normal file
51
pkg/pmapi/manager_download.go
Normal file
@ -0,0 +1,51 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
)
|
||||
|
||||
// DownloadAndVerify downloads a file and its signature from the given locations `file` and `sig`.
|
||||
// The file and its signature are verified using the given keyring `kr`.
|
||||
// If the file is verified successfully, it can be read from the returned reader.
|
||||
// TLS fingerprinting is used to verify that connections are only made to known servers.
|
||||
func (m *manager) DownloadAndVerify(kr *crypto.KeyRing, url, sig string) ([]byte, error) {
|
||||
fb, err := m.fetchFile(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sb, err := m.fetchFile(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := kr.VerifyDetached(
|
||||
crypto.NewPlainMessage(fb),
|
||||
crypto.NewPGPSignature(sb),
|
||||
crypto.GetUnixTime(),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
func (m *manager) fetchFile(url string) ([]byte, error) {
|
||||
res, err := m.rc.R().SetDoNotParseResponse(true).Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(res.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := res.RawBody().Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
11
pkg/pmapi/manager_metrics.go
Normal file
11
pkg/pmapi/manager_metrics.go
Normal file
@ -0,0 +1,11 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (m *manager) SendSimpleMetric(context.Context, string, string, string) error {
|
||||
// FIXME(conman): Implement.
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
11
pkg/pmapi/manager_ping.go
Normal file
11
pkg/pmapi/manager_ping.go
Normal file
@ -0,0 +1,11 @@
|
||||
package pmapi
|
||||
|
||||
import "context"
|
||||
|
||||
func (m *manager) testPing(ctx context.Context) error {
|
||||
if _, err := m.r(ctx).Get("/tests/ping"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
12
pkg/pmapi/manager_report.go
Normal file
12
pkg/pmapi/manager_report.go
Normal file
@ -0,0 +1,12 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Report sends request as json or multipart (if has attachment).
|
||||
func (m *manager) ReportBug(context.Context, ReportBugReq) error {
|
||||
// FIXME(conman): Implement.
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
@ -18,16 +18,18 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testBugReportReq = ReportReq{
|
||||
var testBugReportReq = ReportBugReq{
|
||||
OS: "Mac OSX",
|
||||
OSVersion: "10.11.6",
|
||||
Browser: "AppleMail",
|
||||
@ -40,7 +42,7 @@ var testBugReportReq = ReportReq{
|
||||
Email: "apple@gmail.com",
|
||||
}
|
||||
|
||||
var testBugsCrashReq = ReportReq{
|
||||
var testBugsCrashReq = ReportBugReq{
|
||||
OS: runtime.GOOS,
|
||||
Client: "demoapp",
|
||||
ClientVersion: "GoPMAPI_1.0.14",
|
||||
@ -55,8 +57,9 @@ const testBugsBody = `{
|
||||
|
||||
const testAttachmentJSONZipped = "PK\x03\x04\x14\x00\b\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00last.log\\Rَ\xaaH\x00}ﯨ\xf8r\x1f\xeeܖED;\xe9\ap\x03\x11\x11\x97\x0e8\x99L\xb0(\xa1\xa0\x16\x85b\x91I\xff\xfbD{\x99\xc9}\xab:K\x9d\xa4\xce\xf9\xe7\t\x00\x00z\xf6\xb4\xf7\x02z\xb7a\xe5\xd8\x04*V̭\x8d\xd1lvE}\xd6\xe3\x80\x1f\xd7nX\x9bI[\xa6\xe1a=\xd4a\xa8M\x97\xd9J\xf1F\xeb\x105U\xbd\xb0`XO\xce\xf1hu\x99q\xc3\xfe{\x11ߨ'-\v\x89Z\xa4\x9c5\xaf\xaf\xbd?>R\xd6\x11E\xf7\x1cX\xf0JpF#L\x9eE+\xbe\xe8\x1d\xee\ued2e\u007f\xde]\u06dd\xedo\x97\x87E\xa0V\xf4/$\xc2\xecK\xed\xa0\xdb&\x829\x12\xe5\x9do\xa0\xe9\x1a\xd2\x19\x1e\xf5`\x95гb\xf8\x89\x81\xb7\xa5G\x18\x95\xf3\x9d9\xe8\x93B\x17!\x1a^\xccr\xbb`\xb2\xb4\xb86\x87\xb4h\x0e\xda\xc6u<+\x9e$̓\x95\xccSo\xea\xa4\xdbH!\xe9g\x8b\xd4\b\xb3hܬ\xa6Wk\x14He\xae\x8aPU\xaa\xc1\xee$\xfbH\xb3\xab.I\f<\x89\x06q\xe3-3-\x99\xcdݽ\xe5v\x99\xedn\xac\xadn\xe8Rp=\xb4nJ\xed\xd5\r\x8d\xde\x06Ζ\xf6\xb3\x01\x94\xcb\xf6\xd4\x19r\xe1\xaa$4+\xeaW\xa6F\xfa0\x97\x9cD\f\x8e\xd7\xd6z\v,G\xf3e2\xd4\xe6V\xba\v\xb6\xd9\xe8\xca*\x16\x95V\xa4J\xfbp\xddmF\x8c\x9a\xc6\xc8Č-\xdb\v\xf6\xf5\xf9\x02*\x15e\x874\xc9\xe7\"\xa3\x1an\xabq}ˊq\x957\xd3\xfd\xa91\x82\xe0Lß\\\x17\x8e\x9e_\xed`\t\xe9~5̕\x03\x9a\f\xddN6\xa2\xc4\x17\xdb\xc9V\x1c~\x9e\xea\xbe\xda-xv\xed\x8b\xe2\xc8DŽS\x95E6\xf2\xc3H\x1d:HPx\xc9\x14\xbfɒ\xff\xea\xb4P\x14\xa3\xe2\xfe\xfd\x1f+z\x80\x903\x81\x98\xf8\x15\xa3\x12\x16\xf8\"0g\xf7~B^\xfd \x040T\xa3\x02\x9c\x10\xc1\xa8F\xa0I#\xf1\xa3\x04\x98\x01\x91\xe2\x12\xdc;\x06gL\xd0g\xc0\xe3\xbd\xf6\xd7}&\xa8轀?\xbfяy`X\xf0\x92\x9f\x05\xf0*A8ρ\xac=K\xff\xf3\xfe\xa6Z\xe1\x1a\x017\xc2\x04\f\x94g\xa9\xf7-\xfb\xebqz\u007fz\u007f\xfa7\x00\x00\xff\xffPK\a\b\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00PK\x01\x02\x14\x00\x14\x00\b\x00\b\x00\x00\x00\x00\x00\xf5\\\v\xe5I\x02\x00\x00\r\x03\x00\x00\b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00last.logPK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x006\x00\x00\x00\u007f\x02\x00\x00\x00\x00" //nolint[misspell]
|
||||
|
||||
func TestClient_BugReportWithAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// FIXME(conman): Implement bug reports then enable this test.
|
||||
func _TestClient_BugReportWithAttachment(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
|
||||
@ -86,34 +89,39 @@ func TestClient_BugReportWithAttachment(t *testing.T) {
|
||||
|
||||
Equals(t, []byte(testAttachmentJSONZipped), log)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
cm := newManager(Config{HostURL: s.URL})
|
||||
|
||||
rep := testBugReportReq
|
||||
rep.AddAttachment("log", "last.log", strings.NewReader(testAttachmentJSON))
|
||||
|
||||
Ok(t, c.Report(rep))
|
||||
Ok(t, cm.ReportBug(context.TODO(), rep))
|
||||
}
|
||||
|
||||
func TestClient_BugReport(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// FIXME(conman): Implement bug reports then enable this test.
|
||||
func _TestClient_BugReport(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
|
||||
var bugsReportReq ReportReq
|
||||
var bugsReportReq ReportBugReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq))
|
||||
Equals(t, testBugReportReq, bugsReportReq)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
r := ReportReq{
|
||||
cm := newManager(Config{HostURL: s.URL})
|
||||
|
||||
r := ReportBugReq{
|
||||
OS: testBugReportReq.OS,
|
||||
OSVersion: testBugReportReq.OSVersion,
|
||||
Browser: testBugReportReq.Browser,
|
||||
@ -123,23 +131,5 @@ func TestClient_BugReport(t *testing.T) {
|
||||
Email: testBugReportReq.Email,
|
||||
}
|
||||
|
||||
Ok(t, c.Report(r))
|
||||
}
|
||||
|
||||
func TestClient_BugsCrash(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/reports/crash"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
|
||||
var bugsCrashReq ReportReq
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&bugsCrashReq))
|
||||
Equals(t, testBugsCrashReq, bugsCrashReq)
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
Ok(t, c.ReportCrash(testBugsCrashReq.Debug))
|
||||
Ok(t, cm.ReportBug(context.TODO(), r))
|
||||
}
|
||||
@ -1,20 +1,3 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
@ -22,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -39,8 +20,8 @@ type reportAtt struct {
|
||||
body io.Reader
|
||||
}
|
||||
|
||||
// ReportReq stores data for report.
|
||||
type ReportReq struct {
|
||||
// ReportBugReq stores data for report.
|
||||
type ReportBugReq struct {
|
||||
OS string `json:",omitempty"`
|
||||
OSVersion string `json:",omitempty"`
|
||||
Browser string `json:",omitempty"`
|
||||
@ -62,11 +43,11 @@ type ReportReq struct {
|
||||
}
|
||||
|
||||
// AddAttachment to report.
|
||||
func (rep *ReportReq) AddAttachment(name, filename string, r io.Reader) {
|
||||
func (rep *ReportBugReq) AddAttachment(name, filename string, r io.Reader) {
|
||||
rep.Attachments = append(rep.Attachments, reportAtt{name: name, filename: filename, body: r})
|
||||
}
|
||||
|
||||
func writeMultipartReport(w *multipart.Writer, rep *ReportReq) error { // nolint[funlen]
|
||||
func writeMultipartReport(w *multipart.Writer, rep *ReportBugReq) error { // nolint[funlen]
|
||||
fieldData := map[string]string{
|
||||
"OS": rep.OS,
|
||||
"OSVersion": rep.OSVersion,
|
||||
@ -129,69 +110,3 @@ func writeMultipartReport(w *multipart.Writer, rep *ReportReq) error { // nolint
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Report sends request as json or multipart (if has attachment).
|
||||
func (c *client) Report(rep ReportReq) (err error) {
|
||||
rep.Client = c.cm.config.ClientID
|
||||
rep.ClientVersion = c.cm.config.AppVersion
|
||||
rep.ClientType = EmailClientType
|
||||
|
||||
var req *http.Request
|
||||
var w *MultipartWriter
|
||||
if len(rep.Attachments) > 0 {
|
||||
req, w, err = c.NewMultipartRequest("POST", "/reports/bug")
|
||||
} else {
|
||||
req, err = c.NewJSONRequest("POST", "/reports/bug", rep)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- c.DoJSON(req, &res)
|
||||
}()
|
||||
|
||||
if w != nil {
|
||||
err = writeMultipartReport(w.Writer, &rep)
|
||||
if err != nil {
|
||||
c.log.Errorln("report write: ", err)
|
||||
return
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
c.log.Errorln("report close: ", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = <-done; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return res.Err()
|
||||
}
|
||||
|
||||
// ReportCrash is old. Use sentry instead.
|
||||
func (c *client) ReportCrash(stacktrace string) (err error) {
|
||||
crashReq := ReportReq{
|
||||
Client: c.cm.config.ClientID,
|
||||
ClientVersion: c.cm.config.AppVersion,
|
||||
ClientType: EmailClientType,
|
||||
OS: runtime.GOOS,
|
||||
Debug: stacktrace,
|
||||
}
|
||||
req, err := c.NewJSONRequest("POST", "/reports/crash", crashReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
254
pkg/pmapi/manager_test.go
Normal file
254
pkg/pmapi/manager_test.go
Normal file
@ -0,0 +1,254 @@
|
||||
package pmapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
)
|
||||
|
||||
func TestHandleTooManyRequests(t *testing.T) {
|
||||
var numCalls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numCalls++
|
||||
|
||||
if numCalls < 5 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// The call should succeed because the 5th retry should succeed (429s are retried).
|
||||
if _, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background()); err != nil {
|
||||
t.Fatal("got unexpected error", err)
|
||||
}
|
||||
|
||||
// The server should be called 5 times.
|
||||
// The first four calls should return 429 and the last call should return 200.
|
||||
if numCalls != 5 {
|
||||
t.Fatal("expected numCalls to be 5, instead got", numCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUnprocessableEntity(t *testing.T) {
|
||||
var numCalls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numCalls++
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}))
|
||||
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// The call should fail because the first call should fail (422s are not retried).
|
||||
_, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
// API-side errors get ErrAPIFailure
|
||||
if !errors.Is(err, pmapi.ErrAPIFailure) {
|
||||
t.Fatal("expected error to be ErrAPIFailure, instead got", err)
|
||||
}
|
||||
|
||||
// The server should be called 1 time.
|
||||
// The first call should return 422.
|
||||
if numCalls != 1 {
|
||||
t.Fatal("expected numCalls to be 1, instead got", numCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDialFailure(t *testing.T) {
|
||||
var numCalls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// The failingRoundTripper will fail the first 5 times it is used.
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set a custom transport.
|
||||
m.SetTransport(newFailingRoundTripper(5))
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// The call should succeed because the last retry should succeed (dial errors are retried).
|
||||
if _, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background()); err != nil {
|
||||
t.Fatal("got unexpected error", err)
|
||||
}
|
||||
|
||||
// The server should be called 1 time.
|
||||
// The first 4 attempts don't reach the server.
|
||||
if numCalls != 1 {
|
||||
t.Fatal("expected numCalls to be 1, instead got", numCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTooManyDialFailures(t *testing.T) {
|
||||
var numCalls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// The failingRoundTripper will fail the first 10 times it is used.
|
||||
// This is more than the number of retries we permit.
|
||||
// Thus, dials will fail.
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set a custom transport.
|
||||
m.SetTransport(newFailingRoundTripper(10))
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// The call should fail because every dial will fail and we'll run out of retries.
|
||||
_, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
if !errors.Is(err, pmapi.ErrNoConnection) {
|
||||
t.Fatal("expected error to be ErrNoConnection, instead got", err)
|
||||
}
|
||||
|
||||
// The server should never be called.
|
||||
if numCalls != 0 {
|
||||
t.Fatal("expected numCalls to be 0, instead got", numCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriesWithContextTimeout(t *testing.T) {
|
||||
var numCalls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numCalls++
|
||||
|
||||
if numCalls < 5 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
|
||||
// Theoretically, this should succeed; on the fifth retry, we'll get StatusOK.
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// However, that will take ~5s, and we only allow 1s in the context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Thus, it will fail.
|
||||
_, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal("expected error to be DeadlineExceeded, instead got", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveConnectionStatus(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
var onDown, onUp bool
|
||||
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
|
||||
// Set a custom transport.
|
||||
m.SetTransport(newFailingRoundTripper(10))
|
||||
|
||||
// Set the retry count to 5.
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// Add a connection observer.
|
||||
m.AddConnectionObserver(pmapi.NewConnectionObserver(func() { onDown = true }, func() { onUp = true }))
|
||||
|
||||
// The call should fail because every dial will fail and we'll run out of retries.
|
||||
if _, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background()); err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
if onDown != true || onUp == true {
|
||||
t.Fatal("expected onDown to have been called and onUp to not have been called")
|
||||
}
|
||||
|
||||
onDown, onUp = false, false
|
||||
|
||||
// The call should succeed because the last dial attempt will succeed.
|
||||
if _, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background()); err != nil {
|
||||
t.Fatal("got unexpected error", err)
|
||||
}
|
||||
|
||||
if onDown == true || onUp != true {
|
||||
t.Fatal("expected onUp to have been called and onDown to not have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnErrNoConnection(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// We will fail more times than we retry, so requests should fail with ErrNoConnection.
|
||||
m := pmapi.New(pmapi.Config{HostURL: ts.URL})
|
||||
m.SetTransport(newFailingRoundTripper(10))
|
||||
m.SetRetryCount(5)
|
||||
|
||||
// The call should fail because every dial will fail and we'll run out of retries.
|
||||
_, err := m.NewClient("", "", "", time.Now().Add(time.Hour)).GetAddresses(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, instead got", err)
|
||||
}
|
||||
|
||||
if !errors.Is(err, pmapi.ErrNoConnection) {
|
||||
t.Fatal("expected error to be ErrNoConnection, instead got", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRoundTripper struct {
|
||||
http.RoundTripper
|
||||
|
||||
fails, calls int
|
||||
}
|
||||
|
||||
func newFailingRoundTripper(fails int) http.RoundTripper {
|
||||
return &failingRoundTripper{
|
||||
RoundTripper: http.DefaultTransport,
|
||||
fails: fails,
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *failingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
rt.calls++
|
||||
|
||||
if rt.calls < rt.fails {
|
||||
return nil, errors.New("simulating network error")
|
||||
}
|
||||
|
||||
return rt.RoundTripper.RoundTrip(req)
|
||||
}
|
||||
26
pkg/pmapi/manager_types.go
Normal file
26
pkg/pmapi/manager_types.go
Normal file
@ -0,0 +1,26 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Manager interface {
|
||||
NewClient(string, string, string, time.Time) Client
|
||||
NewClientWithRefresh(context.Context, string, string) (Client, *Auth, error)
|
||||
NewClientWithLogin(context.Context, string, string) (Client, *Auth, error)
|
||||
|
||||
DownloadAndVerify(kr *crypto.KeyRing, url, sig string) ([]byte, error)
|
||||
ReportBug(context.Context, ReportBugReq) error
|
||||
SendSimpleMetric(context.Context, string, string, string) error
|
||||
|
||||
SetLogger(resty.Logger)
|
||||
SetTransport(http.RoundTripper)
|
||||
SetCookieJar(http.CookieJar)
|
||||
SetRetryCount(int)
|
||||
AddConnectionObserver(ConnectionObserver)
|
||||
}
|
||||
@ -18,10 +18,12 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Draft actions.
|
||||
@ -73,21 +75,23 @@ type DraftReq struct {
|
||||
AttachmentKeyPackets []string
|
||||
}
|
||||
|
||||
func (c *client) CreateDraft(m *Message, parent string, action int) (created *Message, err error) {
|
||||
createReq := &DraftReq{Message: m, ParentID: parent, Action: action, AttachmentKeyPackets: []string{}}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/mail/v4/messages", createReq)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) CreateDraft(ctx context.Context, m *Message, parent string, action int) (created *Message, err error) {
|
||||
var res struct {
|
||||
Message *Message
|
||||
}
|
||||
|
||||
var res MessageRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(&DraftReq{
|
||||
Message: m,
|
||||
ParentID: parent,
|
||||
Action: action,
|
||||
AttachmentKeyPackets: []string{},
|
||||
}).SetResult(&res).Post("/mail/v4/messages")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err = res.Message, res.Err()
|
||||
return
|
||||
return res.Message, nil
|
||||
}
|
||||
|
||||
type AlgoKey struct {
|
||||
@ -335,35 +339,25 @@ func (req *SendMessageReq) PreparePackages() {
|
||||
}
|
||||
}
|
||||
|
||||
type SendMessageRes struct {
|
||||
Res
|
||||
func (c *client) SendMessage(ctx context.Context, draftID string, req *SendMessageReq) (*Message, *Message, error) {
|
||||
if draftID == "" {
|
||||
return nil, nil, errors.New("pmapi: cannot send message with an empty draftID")
|
||||
}
|
||||
|
||||
Sent *Message
|
||||
if req.Packages == nil {
|
||||
req.Packages = []*MessagePackage{}
|
||||
}
|
||||
|
||||
// Parent is only present if the sent message has a parent (reply/reply all/forward).
|
||||
Parent *Message
|
||||
}
|
||||
|
||||
func (c *client) SendMessage(id string, sendReq *SendMessageReq) (sent, parent *Message, err error) {
|
||||
if id == "" {
|
||||
err = errors.New("pmapi: cannot send message with an empty id")
|
||||
return
|
||||
}
|
||||
|
||||
if sendReq.Packages == nil {
|
||||
sendReq.Packages = []*MessagePackage{}
|
||||
}
|
||||
|
||||
req, err := c.NewJSONRequest("POST", "/mail/v4/messages/"+id, sendReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res SendMessageRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sent, parent, err = res.Sent, res.Parent, res.Err()
|
||||
return
|
||||
var res struct {
|
||||
Sent *Message
|
||||
Parent *Message
|
||||
}
|
||||
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).SetResult(&res).Post("/mail/v4/messages/" + draftID)
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return res.Sent, res.Parent, nil
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
@ -34,6 +35,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/openpgp/packet"
|
||||
)
|
||||
@ -160,7 +162,7 @@ type Message struct {
|
||||
Order int64 `json:",omitempty"`
|
||||
ConversationID string `json:",omitempty"` // only filter
|
||||
Subject string
|
||||
Unread int
|
||||
Unread Boolean
|
||||
Type int
|
||||
Flags int64
|
||||
Sender *mail.Address
|
||||
@ -496,156 +498,102 @@ func (filter *MessagesFilter) urlValues() url.Values { // nolint[funlen]
|
||||
return v
|
||||
}
|
||||
|
||||
type MessagesListRes struct {
|
||||
Res
|
||||
|
||||
Total int
|
||||
Messages []*Message
|
||||
}
|
||||
|
||||
// ListMessages gets message metadata.
|
||||
func (c *client) ListMessages(filter *MessagesFilter) (msgs []*Message, total int, err error) {
|
||||
req, err := c.NewRequest("GET", "/mail/v4/messages", nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) ListMessages(ctx context.Context, filter *MessagesFilter) ([]*Message, int, error) {
|
||||
var res struct {
|
||||
Messages []*Message
|
||||
Total int
|
||||
}
|
||||
|
||||
req.URL.RawQuery = filter.urlValues().Encode()
|
||||
var res MessagesListRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
// If the URI was too long and we searched with IDs, we will try again without the API IDs.
|
||||
if strings.Contains(err.Error(), "api returned: 414") && len(filter.ID) > 0 {
|
||||
filter.ID = []string{}
|
||||
return c.ListMessages(filter)
|
||||
}
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetQueryParamsFromValues(filter.urlValues()).
|
||||
SetResult(&res).
|
||||
Get("/mail/v4/messages")
|
||||
}); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
msgs, total, err = res.Messages, res.Total, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type MessagesCountsRes struct {
|
||||
Res
|
||||
|
||||
Counts []*MessagesCount
|
||||
return res.Messages, res.Total, nil
|
||||
}
|
||||
|
||||
// CountMessages counts messages by label.
|
||||
func (c *client) CountMessages(addressID string) (counts []*MessagesCount, err error) {
|
||||
reqURL := "/mail/v4/messages/count"
|
||||
if addressID != "" {
|
||||
reqURL += ("?AddressID=" + addressID)
|
||||
}
|
||||
req, err := c.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessagesCountsRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
counts, err = res.Counts, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type MessageRes struct {
|
||||
Res
|
||||
|
||||
Message *Message
|
||||
func (c *client) CountMessages(ctx context.Context, addressID string) (counts []*MessagesCount, err error) {
|
||||
panic("TODO")
|
||||
}
|
||||
|
||||
// GetMessage retrieves a message.
|
||||
func (c *client) GetMessage(id string) (msg *Message, err error) {
|
||||
req, err := c.NewRequest("GET", "/mail/v4/messages/"+id, nil)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) GetMessage(ctx context.Context, messageID string) (msg *Message, err error) {
|
||||
var res struct {
|
||||
Message *Message
|
||||
}
|
||||
|
||||
var res MessageRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/mail/v4/messages/" + messageID)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Message, res.Err()
|
||||
return res.Message, nil
|
||||
}
|
||||
|
||||
type MessagesActionReq struct {
|
||||
IDs []string
|
||||
}
|
||||
|
||||
type MessagesActionRes struct {
|
||||
Res
|
||||
func (c *client) MarkMessagesRead(ctx context.Context, messageIDs []string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := MessagesActionReq{IDs: messageIDs}
|
||||
|
||||
Responses []struct {
|
||||
ID string
|
||||
Response Res
|
||||
}
|
||||
}
|
||||
|
||||
func (res MessagesActionRes) Err() error {
|
||||
if err := res.Res.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, msgRes := range res.Responses {
|
||||
if err := msgRes.Response.Err(); err != nil {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/read")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// doMessagesAction performs paged requests to doMessagesActionInner.
|
||||
// This can eventually be done in parallel though.
|
||||
func (c *client) doMessagesAction(action string, ids []string) (err error) {
|
||||
for len(ids) > messageIDPageSize {
|
||||
var requestIDs []string
|
||||
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
|
||||
if err = c.doMessagesActionInner(action, requestIDs); err != nil {
|
||||
return
|
||||
func (c *client) MarkMessagesUnread(ctx context.Context, messageIDs []string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := MessagesActionReq{IDs: messageIDs}
|
||||
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/unread")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return c.doMessagesActionInner(action, ids)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// doMessagesActionInner is the non-paged inner method of doMessagesAction.
|
||||
// You should not call this directly unless you know what you are doing (it can overload the server).
|
||||
func (c *client) doMessagesActionInner(action string, ids []string) (err error) {
|
||||
actionReq := &MessagesActionReq{IDs: ids}
|
||||
req, err := c.NewJSONRequest("PUT", "/mail/v4/messages/"+action, actionReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
func (c *client) DeleteMessages(ctx context.Context, messageIDs []string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := MessagesActionReq{IDs: messageIDs}
|
||||
|
||||
var res MessagesActionRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/delete")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
|
||||
return
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) MarkMessagesRead(ids []string) error {
|
||||
return c.doMessagesAction("read", ids)
|
||||
}
|
||||
func (c *client) UndeleteMessages(ctx context.Context, messageIDs []string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := MessagesActionReq{IDs: messageIDs}
|
||||
|
||||
func (c *client) MarkMessagesUnread(ids []string) error {
|
||||
return c.doMessagesAction("unread", ids)
|
||||
}
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/undelete")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) DeleteMessages(ids []string) error {
|
||||
return c.doMessagesAction("delete", ids)
|
||||
}
|
||||
|
||||
func (c *client) UndeleteMessages(ids []string) error {
|
||||
return c.doMessagesAction("undelete", ids)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type LabelMessagesReq struct {
|
||||
@ -655,86 +603,54 @@ type LabelMessagesReq struct {
|
||||
|
||||
// LabelMessages labels the given message IDs with the given label.
|
||||
// The requests are performed paged; this can eventually be done in parallel.
|
||||
func (c *client) LabelMessages(ids []string, label string) (err error) {
|
||||
for len(ids) > messageIDPageSize {
|
||||
var requestIDs []string
|
||||
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
|
||||
if err = c.labelMessages(requestIDs, label); err != nil {
|
||||
return
|
||||
func (c *client) LabelMessages(ctx context.Context, messageIDs []string, labelID string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := LabelMessagesReq{
|
||||
LabelID: labelID,
|
||||
IDs: messageIDs,
|
||||
}
|
||||
}
|
||||
|
||||
return c.labelMessages(ids, label)
|
||||
}
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/label")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) labelMessages(ids []string, label string) (err error) {
|
||||
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
|
||||
req, err := c.NewJSONRequest("PUT", "/mail/v4/messages/label", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessagesActionRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnlabelMessages removes the given label from the given message IDs.
|
||||
// The requests are performed paged; this can eventually be done in parallel.
|
||||
func (c *client) UnlabelMessages(ids []string, label string) (err error) {
|
||||
for len(ids) > messageIDPageSize {
|
||||
var requestIDs []string
|
||||
requestIDs, ids = ids[:messageIDPageSize], ids[messageIDPageSize:]
|
||||
if err = c.unlabelMessages(requestIDs, label); err != nil {
|
||||
return
|
||||
func (c *client) UnlabelMessages(ctx context.Context, messageIDs []string, labelID string) error {
|
||||
return doPaged(messageIDs, defaultPageSize, func(messageIDs []string) (err error) {
|
||||
req := LabelMessagesReq{
|
||||
LabelID: labelID,
|
||||
IDs: messageIDs,
|
||||
}
|
||||
}
|
||||
|
||||
return c.unlabelMessages(ids, label)
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetBody(req).Put("/mail/v4/messages/unlabel")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) unlabelMessages(ids []string, label string) (err error) {
|
||||
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
|
||||
req, err := c.NewJSONRequest("PUT", "/mail/v4/messages/unlabel", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
func (c *client) EmptyFolder(ctx context.Context, labelID, addressID string) error {
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
if addressID != "" {
|
||||
r.SetQueryParam("AddressID", addressID)
|
||||
}
|
||||
|
||||
return r.SetQueryParam("LabelID", labelID).Delete("/mail/v4/messages/empty")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var res MessagesActionRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) EmptyFolder(labelID, addressID string) (err error) {
|
||||
if labelID == "" {
|
||||
return errors.New("pmapi: labelID parameter is empty string")
|
||||
}
|
||||
reqURL := "/mail/v4/messages/empty?LabelID=" + labelID
|
||||
if addressID != "" {
|
||||
reqURL += ("&AddressID=" + addressID)
|
||||
}
|
||||
|
||||
req, err := c.NewRequest("DELETE", reqURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeMessageFlagsByLabels returns flags based on labels.
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
@ -197,15 +198,12 @@ func TestMessage_LabelMessages_NoPaging(t *testing.T) {
|
||||
}
|
||||
|
||||
// There should be enough IDs to produce just one page so the endpoint should be called once.
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
routeLabelMessages,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
|
||||
assert.NoError(t, c.LabelMessages(context.TODO(), testIDs, "mylabel"))
|
||||
}
|
||||
|
||||
func TestMessage_LabelMessages_Paging(t *testing.T) {
|
||||
@ -216,15 +214,12 @@ func TestMessage_LabelMessages_Paging(t *testing.T) {
|
||||
}
|
||||
|
||||
// There should be enough IDs to produce three pages so the endpoint should be called three times.
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
routeLabelMessages,
|
||||
routeLabelMessages,
|
||||
routeLabelMessages,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
|
||||
assert.NoError(t, c.LabelMessages(context.TODO(), testIDs, "mylabel"))
|
||||
}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// SendSimpleMetric makes a simple GET request to send a simple metrics report.
|
||||
func (c *client) SendSimpleMetric(category, action, label string) (err error) {
|
||||
v := url.Values{}
|
||||
v.Set("Category", category)
|
||||
v.Set("Action", action)
|
||||
v.Set("Label", label)
|
||||
|
||||
req, err := c.NewRequest("GET", "/metrics?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
@ -18,8 +18,10 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -28,15 +30,20 @@ const testSendSimpleMetricsBody = `{
|
||||
}
|
||||
`
|
||||
|
||||
func TestClient_SendSimpleMetric(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// FIXME(conman): Implement metrics then enable this test.
|
||||
func _TestClient_SendSimpleMetric(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/metrics?Action=some_action&Category=some_category&Label=some_label"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Fprint(w, testSendSimpleMetricsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
|
||||
m := newManager(Config{HostURL: s.URL})
|
||||
|
||||
err := m.SendSimpleMetric(context.TODO(), "some_category", "some_action", "some_label")
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while sending simple metric, got:", err)
|
||||
}
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/ProtonMail/proton-bridge/pkg/pmapi (interfaces: Client)
|
||||
// Source: github.com/ProtonMail/proton-bridge/pkg/pmapi (interfaces: Client,Manager)
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
io "io"
|
||||
http "net/http"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
crypto "github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
pmapi "github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
resty "github.com/go-resty/resty/v2"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
@ -36,6 +40,18 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AddAuthHandler mocks base method
|
||||
func (m *MockClient) AddAuthHandler(arg0 pmapi.AuthHandler) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "AddAuthHandler", arg0)
|
||||
}
|
||||
|
||||
// AddAuthHandler indicates an expected call of AddAuthHandler
|
||||
func (mr *MockClientMockRecorder) AddAuthHandler(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthHandler", reflect.TypeOf((*MockClient)(nil).AddAuthHandler), arg0)
|
||||
}
|
||||
|
||||
// Addresses mocks base method
|
||||
func (m *MockClient) Addresses() pmapi.AddressList {
|
||||
m.ctrl.T.Helper()
|
||||
@ -50,23 +66,8 @@ func (mr *MockClientMockRecorder) Addresses() *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Addresses", reflect.TypeOf((*MockClient)(nil).Addresses))
|
||||
}
|
||||
|
||||
// Auth mocks base method
|
||||
func (m *MockClient) Auth(arg0, arg1 string, arg2 *pmapi.AuthInfo) (*pmapi.Auth, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Auth", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*pmapi.Auth)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Auth indicates an expected call of Auth
|
||||
func (mr *MockClientMockRecorder) Auth(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Auth", reflect.TypeOf((*MockClient)(nil).Auth), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// Auth2FA mocks base method
|
||||
func (m *MockClient) Auth2FA(arg0 string, arg1 *pmapi.Auth) error {
|
||||
func (m *MockClient) Auth2FA(arg0 context.Context, arg1 pmapi.Auth2FAReq) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Auth2FA", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
@ -79,148 +80,108 @@ func (mr *MockClientMockRecorder) Auth2FA(arg0, arg1 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Auth2FA", reflect.TypeOf((*MockClient)(nil).Auth2FA), arg0, arg1)
|
||||
}
|
||||
|
||||
// AuthInfo mocks base method
|
||||
func (m *MockClient) AuthInfo(arg0 string) (*pmapi.AuthInfo, error) {
|
||||
// AuthDelete mocks base method
|
||||
func (m *MockClient) AuthDelete(arg0 context.Context) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthInfo", arg0)
|
||||
ret0, _ := ret[0].(*pmapi.AuthInfo)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
ret := m.ctrl.Call(m, "AuthDelete", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AuthInfo indicates an expected call of AuthInfo
|
||||
func (mr *MockClientMockRecorder) AuthInfo(arg0 interface{}) *gomock.Call {
|
||||
// AuthDelete indicates an expected call of AuthDelete
|
||||
func (mr *MockClientMockRecorder) AuthDelete(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthInfo", reflect.TypeOf((*MockClient)(nil).AuthInfo), arg0)
|
||||
}
|
||||
|
||||
// AuthRefresh mocks base method
|
||||
func (m *MockClient) AuthRefresh(arg0 string) (*pmapi.Auth, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthRefresh", arg0)
|
||||
ret0, _ := ret[0].(*pmapi.Auth)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthRefresh indicates an expected call of AuthRefresh
|
||||
func (mr *MockClientMockRecorder) AuthRefresh(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthRefresh", reflect.TypeOf((*MockClient)(nil).AuthRefresh), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthDelete", reflect.TypeOf((*MockClient)(nil).AuthDelete), arg0)
|
||||
}
|
||||
|
||||
// AuthSalt mocks base method
|
||||
func (m *MockClient) AuthSalt() (string, error) {
|
||||
func (m *MockClient) AuthSalt(arg0 context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthSalt")
|
||||
ret := m.ctrl.Call(m, "AuthSalt", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthSalt indicates an expected call of AuthSalt
|
||||
func (mr *MockClientMockRecorder) AuthSalt() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) AuthSalt(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthSalt", reflect.TypeOf((*MockClient)(nil).AuthSalt))
|
||||
}
|
||||
|
||||
// ClearData mocks base method
|
||||
func (m *MockClient) ClearData() {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "ClearData")
|
||||
}
|
||||
|
||||
// ClearData indicates an expected call of ClearData
|
||||
func (mr *MockClientMockRecorder) ClearData() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearData", reflect.TypeOf((*MockClient)(nil).ClearData))
|
||||
}
|
||||
|
||||
// CloseConnections mocks base method
|
||||
func (m *MockClient) CloseConnections() {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "CloseConnections")
|
||||
}
|
||||
|
||||
// CloseConnections indicates an expected call of CloseConnections
|
||||
func (mr *MockClientMockRecorder) CloseConnections() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseConnections", reflect.TypeOf((*MockClient)(nil).CloseConnections))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthSalt", reflect.TypeOf((*MockClient)(nil).AuthSalt), arg0)
|
||||
}
|
||||
|
||||
// CountMessages mocks base method
|
||||
func (m *MockClient) CountMessages(arg0 string) ([]*pmapi.MessagesCount, error) {
|
||||
func (m *MockClient) CountMessages(arg0 context.Context, arg1 string) ([]*pmapi.MessagesCount, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountMessages", arg0)
|
||||
ret := m.ctrl.Call(m, "CountMessages", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*pmapi.MessagesCount)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountMessages indicates an expected call of CountMessages
|
||||
func (mr *MockClientMockRecorder) CountMessages(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) CountMessages(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountMessages", reflect.TypeOf((*MockClient)(nil).CountMessages), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountMessages", reflect.TypeOf((*MockClient)(nil).CountMessages), arg0, arg1)
|
||||
}
|
||||
|
||||
// CreateAttachment mocks base method
|
||||
func (m *MockClient) CreateAttachment(arg0 *pmapi.Attachment, arg1, arg2 io.Reader) (*pmapi.Attachment, error) {
|
||||
func (m *MockClient) CreateAttachment(arg0 context.Context, arg1 *pmapi.Attachment, arg2, arg3 io.Reader) (*pmapi.Attachment, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateAttachment", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "CreateAttachment", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(*pmapi.Attachment)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateAttachment indicates an expected call of CreateAttachment
|
||||
func (mr *MockClientMockRecorder) CreateAttachment(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) CreateAttachment(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAttachment", reflect.TypeOf((*MockClient)(nil).CreateAttachment), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAttachment", reflect.TypeOf((*MockClient)(nil).CreateAttachment), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// CreateDraft mocks base method
|
||||
func (m *MockClient) CreateDraft(arg0 *pmapi.Message, arg1 string, arg2 int) (*pmapi.Message, error) {
|
||||
func (m *MockClient) CreateDraft(arg0 context.Context, arg1 *pmapi.Message, arg2 string, arg3 int) (*pmapi.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateDraft", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "CreateDraft", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(*pmapi.Message)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateDraft indicates an expected call of CreateDraft
|
||||
func (mr *MockClientMockRecorder) CreateDraft(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) CreateDraft(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDraft", reflect.TypeOf((*MockClient)(nil).CreateDraft), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDraft", reflect.TypeOf((*MockClient)(nil).CreateDraft), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// CreateLabel mocks base method
|
||||
func (m *MockClient) CreateLabel(arg0 *pmapi.Label) (*pmapi.Label, error) {
|
||||
func (m *MockClient) CreateLabel(arg0 context.Context, arg1 *pmapi.Label) (*pmapi.Label, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateLabel", arg0)
|
||||
ret := m.ctrl.Call(m, "CreateLabel", arg0, arg1)
|
||||
ret0, _ := ret[0].(*pmapi.Label)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateLabel indicates an expected call of CreateLabel
|
||||
func (mr *MockClientMockRecorder) CreateLabel(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) CreateLabel(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLabel", reflect.TypeOf((*MockClient)(nil).CreateLabel), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLabel", reflect.TypeOf((*MockClient)(nil).CreateLabel), arg0, arg1)
|
||||
}
|
||||
|
||||
// CurrentUser mocks base method
|
||||
func (m *MockClient) CurrentUser() (*pmapi.User, error) {
|
||||
func (m *MockClient) CurrentUser(arg0 context.Context) (*pmapi.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CurrentUser")
|
||||
ret := m.ctrl.Call(m, "CurrentUser", arg0)
|
||||
ret0, _ := ret[0].(*pmapi.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CurrentUser indicates an expected call of CurrentUser
|
||||
func (mr *MockClientMockRecorder) CurrentUser() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) CurrentUser(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentUser", reflect.TypeOf((*MockClient)(nil).CurrentUser))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentUser", reflect.TypeOf((*MockClient)(nil).CurrentUser), arg0)
|
||||
}
|
||||
|
||||
// DecryptAndVerifyCards mocks base method
|
||||
@ -238,200 +199,157 @@ func (mr *MockClientMockRecorder) DecryptAndVerifyCards(arg0 interface{}) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DecryptAndVerifyCards", reflect.TypeOf((*MockClient)(nil).DecryptAndVerifyCards), arg0)
|
||||
}
|
||||
|
||||
// DeleteAttachment mocks base method
|
||||
func (m *MockClient) DeleteAttachment(arg0 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAttachment", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAttachment indicates an expected call of DeleteAttachment
|
||||
func (mr *MockClientMockRecorder) DeleteAttachment(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAttachment", reflect.TypeOf((*MockClient)(nil).DeleteAttachment), arg0)
|
||||
}
|
||||
|
||||
// DeleteAuth mocks base method
|
||||
func (m *MockClient) DeleteAuth() error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAuth")
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteAuth indicates an expected call of DeleteAuth
|
||||
func (mr *MockClientMockRecorder) DeleteAuth() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuth", reflect.TypeOf((*MockClient)(nil).DeleteAuth))
|
||||
}
|
||||
|
||||
// DeleteLabel mocks base method
|
||||
func (m *MockClient) DeleteLabel(arg0 string) error {
|
||||
func (m *MockClient) DeleteLabel(arg0 context.Context, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteLabel", arg0)
|
||||
ret := m.ctrl.Call(m, "DeleteLabel", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteLabel indicates an expected call of DeleteLabel
|
||||
func (mr *MockClientMockRecorder) DeleteLabel(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) DeleteLabel(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLabel", reflect.TypeOf((*MockClient)(nil).DeleteLabel), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLabel", reflect.TypeOf((*MockClient)(nil).DeleteLabel), arg0, arg1)
|
||||
}
|
||||
|
||||
// DeleteMessages mocks base method
|
||||
func (m *MockClient) DeleteMessages(arg0 []string) error {
|
||||
func (m *MockClient) DeleteMessages(arg0 context.Context, arg1 []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteMessages", arg0)
|
||||
ret := m.ctrl.Call(m, "DeleteMessages", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteMessages indicates an expected call of DeleteMessages
|
||||
func (mr *MockClientMockRecorder) DeleteMessages(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) DeleteMessages(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMessages", reflect.TypeOf((*MockClient)(nil).DeleteMessages), arg0)
|
||||
}
|
||||
|
||||
// DownloadAndVerify mocks base method
|
||||
func (m *MockClient) DownloadAndVerify(arg0, arg1 string, arg2 *crypto.KeyRing) (io.Reader, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DownloadAndVerify", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(io.Reader)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DownloadAndVerify indicates an expected call of DownloadAndVerify
|
||||
func (mr *MockClientMockRecorder) DownloadAndVerify(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndVerify", reflect.TypeOf((*MockClient)(nil).DownloadAndVerify), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMessages", reflect.TypeOf((*MockClient)(nil).DeleteMessages), arg0, arg1)
|
||||
}
|
||||
|
||||
// EmptyFolder mocks base method
|
||||
func (m *MockClient) EmptyFolder(arg0, arg1 string) error {
|
||||
func (m *MockClient) EmptyFolder(arg0 context.Context, arg1, arg2 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "EmptyFolder", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "EmptyFolder", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// EmptyFolder indicates an expected call of EmptyFolder
|
||||
func (mr *MockClientMockRecorder) EmptyFolder(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) EmptyFolder(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EmptyFolder", reflect.TypeOf((*MockClient)(nil).EmptyFolder), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EmptyFolder", reflect.TypeOf((*MockClient)(nil).EmptyFolder), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// GetAddresses mocks base method
|
||||
func (m *MockClient) GetAddresses() (pmapi.AddressList, error) {
|
||||
func (m *MockClient) GetAddresses(arg0 context.Context) (pmapi.AddressList, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAddresses")
|
||||
ret := m.ctrl.Call(m, "GetAddresses", arg0)
|
||||
ret0, _ := ret[0].(pmapi.AddressList)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAddresses indicates an expected call of GetAddresses
|
||||
func (mr *MockClientMockRecorder) GetAddresses() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetAddresses(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAddresses", reflect.TypeOf((*MockClient)(nil).GetAddresses))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAddresses", reflect.TypeOf((*MockClient)(nil).GetAddresses), arg0)
|
||||
}
|
||||
|
||||
// GetAttachment mocks base method
|
||||
func (m *MockClient) GetAttachment(arg0 string) (io.ReadCloser, error) {
|
||||
func (m *MockClient) GetAttachment(arg0 context.Context, arg1 string) (io.ReadCloser, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAttachment", arg0)
|
||||
ret := m.ctrl.Call(m, "GetAttachment", arg0, arg1)
|
||||
ret0, _ := ret[0].(io.ReadCloser)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAttachment indicates an expected call of GetAttachment
|
||||
func (mr *MockClientMockRecorder) GetAttachment(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetAttachment(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttachment", reflect.TypeOf((*MockClient)(nil).GetAttachment), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttachment", reflect.TypeOf((*MockClient)(nil).GetAttachment), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetContactByID mocks base method
|
||||
func (m *MockClient) GetContactByID(arg0 string) (pmapi.Contact, error) {
|
||||
func (m *MockClient) GetContactByID(arg0 context.Context, arg1 string) (pmapi.Contact, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetContactByID", arg0)
|
||||
ret := m.ctrl.Call(m, "GetContactByID", arg0, arg1)
|
||||
ret0, _ := ret[0].(pmapi.Contact)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetContactByID indicates an expected call of GetContactByID
|
||||
func (mr *MockClientMockRecorder) GetContactByID(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetContactByID(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactByID", reflect.TypeOf((*MockClient)(nil).GetContactByID), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactByID", reflect.TypeOf((*MockClient)(nil).GetContactByID), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetContactEmailByEmail mocks base method
|
||||
func (m *MockClient) GetContactEmailByEmail(arg0 string, arg1, arg2 int) ([]pmapi.ContactEmail, error) {
|
||||
func (m *MockClient) GetContactEmailByEmail(arg0 context.Context, arg1 string, arg2, arg3 int) ([]pmapi.ContactEmail, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetContactEmailByEmail", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "GetContactEmailByEmail", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].([]pmapi.ContactEmail)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetContactEmailByEmail indicates an expected call of GetContactEmailByEmail
|
||||
func (mr *MockClientMockRecorder) GetContactEmailByEmail(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetContactEmailByEmail(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactEmailByEmail", reflect.TypeOf((*MockClient)(nil).GetContactEmailByEmail), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContactEmailByEmail", reflect.TypeOf((*MockClient)(nil).GetContactEmailByEmail), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// GetEvent mocks base method
|
||||
func (m *MockClient) GetEvent(arg0 string) (*pmapi.Event, error) {
|
||||
func (m *MockClient) GetEvent(arg0 context.Context, arg1 string) (*pmapi.Event, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetEvent", arg0)
|
||||
ret := m.ctrl.Call(m, "GetEvent", arg0, arg1)
|
||||
ret0, _ := ret[0].(*pmapi.Event)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetEvent indicates an expected call of GetEvent
|
||||
func (mr *MockClientMockRecorder) GetEvent(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetEvent(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvent", reflect.TypeOf((*MockClient)(nil).GetEvent), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvent", reflect.TypeOf((*MockClient)(nil).GetEvent), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetMailSettings mocks base method
|
||||
func (m *MockClient) GetMailSettings() (pmapi.MailSettings, error) {
|
||||
func (m *MockClient) GetMailSettings(arg0 context.Context) (pmapi.MailSettings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetMailSettings")
|
||||
ret := m.ctrl.Call(m, "GetMailSettings", arg0)
|
||||
ret0, _ := ret[0].(pmapi.MailSettings)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetMailSettings indicates an expected call of GetMailSettings
|
||||
func (mr *MockClientMockRecorder) GetMailSettings() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetMailSettings(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMailSettings", reflect.TypeOf((*MockClient)(nil).GetMailSettings))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMailSettings", reflect.TypeOf((*MockClient)(nil).GetMailSettings), arg0)
|
||||
}
|
||||
|
||||
// GetMessage mocks base method
|
||||
func (m *MockClient) GetMessage(arg0 string) (*pmapi.Message, error) {
|
||||
func (m *MockClient) GetMessage(arg0 context.Context, arg1 string) (*pmapi.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetMessage", arg0)
|
||||
ret := m.ctrl.Call(m, "GetMessage", arg0, arg1)
|
||||
ret0, _ := ret[0].(*pmapi.Message)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetMessage indicates an expected call of GetMessage
|
||||
func (mr *MockClientMockRecorder) GetMessage(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetMessage(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessage", reflect.TypeOf((*MockClient)(nil).GetMessage), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessage", reflect.TypeOf((*MockClient)(nil).GetMessage), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetPublicKeysForEmail mocks base method
|
||||
func (m *MockClient) GetPublicKeysForEmail(arg0 string) ([]pmapi.PublicKey, bool, error) {
|
||||
func (m *MockClient) GetPublicKeysForEmail(arg0 context.Context, arg1 string) ([]pmapi.PublicKey, bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPublicKeysForEmail", arg0)
|
||||
ret := m.ctrl.Call(m, "GetPublicKeysForEmail", arg0, arg1)
|
||||
ret0, _ := ret[0].([]pmapi.PublicKey)
|
||||
ret1, _ := ret[1].(bool)
|
||||
ret2, _ := ret[2].(error)
|
||||
@ -439,38 +357,24 @@ func (m *MockClient) GetPublicKeysForEmail(arg0 string) ([]pmapi.PublicKey, bool
|
||||
}
|
||||
|
||||
// GetPublicKeysForEmail indicates an expected call of GetPublicKeysForEmail
|
||||
func (mr *MockClientMockRecorder) GetPublicKeysForEmail(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) GetPublicKeysForEmail(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicKeysForEmail", reflect.TypeOf((*MockClient)(nil).GetPublicKeysForEmail), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicKeysForEmail", reflect.TypeOf((*MockClient)(nil).GetPublicKeysForEmail), arg0, arg1)
|
||||
}
|
||||
|
||||
// Import mocks base method
|
||||
func (m *MockClient) Import(arg0 []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) {
|
||||
func (m *MockClient) Import(arg0 context.Context, arg1 pmapi.ImportMsgReqs) ([]*pmapi.ImportMsgRes, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Import", arg0)
|
||||
ret := m.ctrl.Call(m, "Import", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*pmapi.ImportMsgRes)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Import indicates an expected call of Import
|
||||
func (mr *MockClientMockRecorder) Import(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) Import(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Import", reflect.TypeOf((*MockClient)(nil).Import), arg0)
|
||||
}
|
||||
|
||||
// IsConnected mocks base method
|
||||
func (m *MockClient) IsConnected() bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsConnected")
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// IsConnected indicates an expected call of IsConnected
|
||||
func (mr *MockClientMockRecorder) IsConnected() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConnected", reflect.TypeOf((*MockClient)(nil).IsConnected))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Import", reflect.TypeOf((*MockClient)(nil).Import), arg0, arg1)
|
||||
}
|
||||
|
||||
// IsUnlocked mocks base method
|
||||
@ -503,38 +407,38 @@ func (mr *MockClientMockRecorder) KeyRingForAddressID(arg0 interface{}) *gomock.
|
||||
}
|
||||
|
||||
// LabelMessages mocks base method
|
||||
func (m *MockClient) LabelMessages(arg0 []string, arg1 string) error {
|
||||
func (m *MockClient) LabelMessages(arg0 context.Context, arg1 []string, arg2 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "LabelMessages", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "LabelMessages", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// LabelMessages indicates an expected call of LabelMessages
|
||||
func (mr *MockClientMockRecorder) LabelMessages(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) LabelMessages(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LabelMessages", reflect.TypeOf((*MockClient)(nil).LabelMessages), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LabelMessages", reflect.TypeOf((*MockClient)(nil).LabelMessages), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// ListLabels mocks base method
|
||||
func (m *MockClient) ListLabels() ([]*pmapi.Label, error) {
|
||||
func (m *MockClient) ListLabels(arg0 context.Context) ([]*pmapi.Label, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListLabels")
|
||||
ret := m.ctrl.Call(m, "ListLabels", arg0)
|
||||
ret0, _ := ret[0].([]*pmapi.Label)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListLabels indicates an expected call of ListLabels
|
||||
func (mr *MockClientMockRecorder) ListLabels() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) ListLabels(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLabels", reflect.TypeOf((*MockClient)(nil).ListLabels))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLabels", reflect.TypeOf((*MockClient)(nil).ListLabels), arg0)
|
||||
}
|
||||
|
||||
// ListMessages mocks base method
|
||||
func (m *MockClient) ListMessages(arg0 *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) {
|
||||
func (m *MockClient) ListMessages(arg0 context.Context, arg1 *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListMessages", arg0)
|
||||
ret := m.ctrl.Call(m, "ListMessages", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*pmapi.Message)
|
||||
ret1, _ := ret[1].(int)
|
||||
ret2, _ := ret[2].(error)
|
||||
@ -542,97 +446,71 @@ func (m *MockClient) ListMessages(arg0 *pmapi.MessagesFilter) ([]*pmapi.Message,
|
||||
}
|
||||
|
||||
// ListMessages indicates an expected call of ListMessages
|
||||
func (mr *MockClientMockRecorder) ListMessages(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) ListMessages(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMessages", reflect.TypeOf((*MockClient)(nil).ListMessages), arg0)
|
||||
}
|
||||
|
||||
// Logout mocks base method
|
||||
func (m *MockClient) Logout() {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Logout")
|
||||
}
|
||||
|
||||
// Logout indicates an expected call of Logout
|
||||
func (mr *MockClientMockRecorder) Logout() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockClient)(nil).Logout))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMessages", reflect.TypeOf((*MockClient)(nil).ListMessages), arg0, arg1)
|
||||
}
|
||||
|
||||
// MarkMessagesRead mocks base method
|
||||
func (m *MockClient) MarkMessagesRead(arg0 []string) error {
|
||||
func (m *MockClient) MarkMessagesRead(arg0 context.Context, arg1 []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarkMessagesRead", arg0)
|
||||
ret := m.ctrl.Call(m, "MarkMessagesRead", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// MarkMessagesRead indicates an expected call of MarkMessagesRead
|
||||
func (mr *MockClientMockRecorder) MarkMessagesRead(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) MarkMessagesRead(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesRead", reflect.TypeOf((*MockClient)(nil).MarkMessagesRead), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesRead", reflect.TypeOf((*MockClient)(nil).MarkMessagesRead), arg0, arg1)
|
||||
}
|
||||
|
||||
// MarkMessagesUnread mocks base method
|
||||
func (m *MockClient) MarkMessagesUnread(arg0 []string) error {
|
||||
func (m *MockClient) MarkMessagesUnread(arg0 context.Context, arg1 []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MarkMessagesUnread", arg0)
|
||||
ret := m.ctrl.Call(m, "MarkMessagesUnread", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// MarkMessagesUnread indicates an expected call of MarkMessagesUnread
|
||||
func (mr *MockClientMockRecorder) MarkMessagesUnread(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) MarkMessagesUnread(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesUnread", reflect.TypeOf((*MockClient)(nil).MarkMessagesUnread), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkMessagesUnread", reflect.TypeOf((*MockClient)(nil).MarkMessagesUnread), arg0, arg1)
|
||||
}
|
||||
|
||||
// ReloadKeys mocks base method
|
||||
func (m *MockClient) ReloadKeys(arg0 []byte) error {
|
||||
func (m *MockClient) ReloadKeys(arg0 context.Context, arg1 []byte) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReloadKeys", arg0)
|
||||
ret := m.ctrl.Call(m, "ReloadKeys", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ReloadKeys indicates an expected call of ReloadKeys
|
||||
func (mr *MockClientMockRecorder) ReloadKeys(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) ReloadKeys(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadKeys", reflect.TypeOf((*MockClient)(nil).ReloadKeys), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadKeys", reflect.TypeOf((*MockClient)(nil).ReloadKeys), arg0, arg1)
|
||||
}
|
||||
|
||||
// ReorderAddresses mocks base method
|
||||
func (m *MockClient) ReorderAddresses(arg0 []string) error {
|
||||
func (m *MockClient) ReorderAddresses(arg0 context.Context, arg1 []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReorderAddresses", arg0)
|
||||
ret := m.ctrl.Call(m, "ReorderAddresses", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ReorderAddresses indicates an expected call of ReorderAddresses
|
||||
func (mr *MockClientMockRecorder) ReorderAddresses(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) ReorderAddresses(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderAddresses", reflect.TypeOf((*MockClient)(nil).ReorderAddresses), arg0)
|
||||
}
|
||||
|
||||
// Report mocks base method
|
||||
func (m *MockClient) Report(arg0 pmapi.ReportReq) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Report", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Report indicates an expected call of Report
|
||||
func (mr *MockClientMockRecorder) Report(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Report", reflect.TypeOf((*MockClient)(nil).Report), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderAddresses", reflect.TypeOf((*MockClient)(nil).ReorderAddresses), arg0, arg1)
|
||||
}
|
||||
|
||||
// SendMessage mocks base method
|
||||
func (m *MockClient) SendMessage(arg0 string, arg1 *pmapi.SendMessageReq) (*pmapi.Message, *pmapi.Message, error) {
|
||||
func (m *MockClient) SendMessage(arg0 context.Context, arg1 string, arg2 *pmapi.SendMessageReq) (*pmapi.Message, *pmapi.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SendMessage", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SendMessage", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*pmapi.Message)
|
||||
ret1, _ := ret[1].(*pmapi.Message)
|
||||
ret2, _ := ret[2].(error)
|
||||
@ -640,79 +518,237 @@ func (m *MockClient) SendMessage(arg0 string, arg1 *pmapi.SendMessageReq) (*pmap
|
||||
}
|
||||
|
||||
// SendMessage indicates an expected call of SendMessage
|
||||
func (mr *MockClientMockRecorder) SendMessage(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) SendMessage(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockClient)(nil).SendMessage), arg0, arg1)
|
||||
}
|
||||
|
||||
// SendSimpleMetric mocks base method
|
||||
func (m *MockClient) SendSimpleMetric(arg0, arg1, arg2 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SendSimpleMetric", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SendSimpleMetric indicates an expected call of SendSimpleMetric
|
||||
func (mr *MockClientMockRecorder) SendSimpleMetric(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendSimpleMetric", reflect.TypeOf((*MockClient)(nil).SendSimpleMetric), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockClient)(nil).SendMessage), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// UnlabelMessages mocks base method
|
||||
func (m *MockClient) UnlabelMessages(arg0 []string, arg1 string) error {
|
||||
func (m *MockClient) UnlabelMessages(arg0 context.Context, arg1 []string, arg2 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnlabelMessages", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "UnlabelMessages", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UnlabelMessages indicates an expected call of UnlabelMessages
|
||||
func (mr *MockClientMockRecorder) UnlabelMessages(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) UnlabelMessages(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlabelMessages", reflect.TypeOf((*MockClient)(nil).UnlabelMessages), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlabelMessages", reflect.TypeOf((*MockClient)(nil).UnlabelMessages), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// Unlock mocks base method
|
||||
func (m *MockClient) Unlock(arg0 []byte) error {
|
||||
func (m *MockClient) Unlock(arg0 context.Context, arg1 []byte) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Unlock", arg0)
|
||||
ret := m.ctrl.Call(m, "Unlock", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Unlock indicates an expected call of Unlock
|
||||
func (mr *MockClientMockRecorder) Unlock(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) Unlock(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockClient)(nil).Unlock), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockClient)(nil).Unlock), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateLabel mocks base method
|
||||
func (m *MockClient) UpdateLabel(arg0 *pmapi.Label) (*pmapi.Label, error) {
|
||||
func (m *MockClient) UpdateLabel(arg0 context.Context, arg1 *pmapi.Label) (*pmapi.Label, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateLabel", arg0)
|
||||
ret := m.ctrl.Call(m, "UpdateLabel", arg0, arg1)
|
||||
ret0, _ := ret[0].(*pmapi.Label)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateLabel indicates an expected call of UpdateLabel
|
||||
func (mr *MockClientMockRecorder) UpdateLabel(arg0 interface{}) *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) UpdateLabel(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLabel", reflect.TypeOf((*MockClient)(nil).UpdateLabel), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLabel", reflect.TypeOf((*MockClient)(nil).UpdateLabel), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateUser mocks base method
|
||||
func (m *MockClient) UpdateUser() (*pmapi.User, error) {
|
||||
func (m *MockClient) UpdateUser(arg0 context.Context) (*pmapi.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUser")
|
||||
ret := m.ctrl.Call(m, "UpdateUser", arg0)
|
||||
ret0, _ := ret[0].(*pmapi.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUser indicates an expected call of UpdateUser
|
||||
func (mr *MockClientMockRecorder) UpdateUser() *gomock.Call {
|
||||
func (mr *MockClientMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockClient)(nil).UpdateUser))
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockClient)(nil).UpdateUser), arg0)
|
||||
}
|
||||
|
||||
// MockManager is a mock of Manager interface
|
||||
type MockManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockManagerMockRecorder
|
||||
}
|
||||
|
||||
// MockManagerMockRecorder is the mock recorder for MockManager
|
||||
type MockManagerMockRecorder struct {
|
||||
mock *MockManager
|
||||
}
|
||||
|
||||
// NewMockManager creates a new mock instance
|
||||
func NewMockManager(ctrl *gomock.Controller) *MockManager {
|
||||
mock := &MockManager{ctrl: ctrl}
|
||||
mock.recorder = &MockManagerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockManager) EXPECT() *MockManagerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AddConnectionObserver mocks base method
|
||||
func (m *MockManager) AddConnectionObserver(arg0 pmapi.ConnectionObserver) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "AddConnectionObserver", arg0)
|
||||
}
|
||||
|
||||
// AddConnectionObserver indicates an expected call of AddConnectionObserver
|
||||
func (mr *MockManagerMockRecorder) AddConnectionObserver(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddConnectionObserver", reflect.TypeOf((*MockManager)(nil).AddConnectionObserver), arg0)
|
||||
}
|
||||
|
||||
// DownloadAndVerify mocks base method
|
||||
func (m *MockManager) DownloadAndVerify(arg0 *crypto.KeyRing, arg1, arg2 string) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DownloadAndVerify", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DownloadAndVerify indicates an expected call of DownloadAndVerify
|
||||
func (mr *MockManagerMockRecorder) DownloadAndVerify(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndVerify", reflect.TypeOf((*MockManager)(nil).DownloadAndVerify), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// NewClient mocks base method
|
||||
func (m *MockManager) NewClient(arg0, arg1, arg2 string, arg3 time.Time) pmapi.Client {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewClient", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(pmapi.Client)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// NewClient indicates an expected call of NewClient
|
||||
func (mr *MockManagerMockRecorder) NewClient(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewClient", reflect.TypeOf((*MockManager)(nil).NewClient), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// NewClientWithLogin mocks base method
|
||||
func (m *MockManager) NewClientWithLogin(arg0 context.Context, arg1, arg2 string) (pmapi.Client, *pmapi.Auth, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewClientWithLogin", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(pmapi.Client)
|
||||
ret1, _ := ret[1].(*pmapi.Auth)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// NewClientWithLogin indicates an expected call of NewClientWithLogin
|
||||
func (mr *MockManagerMockRecorder) NewClientWithLogin(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewClientWithLogin", reflect.TypeOf((*MockManager)(nil).NewClientWithLogin), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// NewClientWithRefresh mocks base method
|
||||
func (m *MockManager) NewClientWithRefresh(arg0 context.Context, arg1, arg2 string) (pmapi.Client, *pmapi.Auth, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewClientWithRefresh", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(pmapi.Client)
|
||||
ret1, _ := ret[1].(*pmapi.Auth)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// NewClientWithRefresh indicates an expected call of NewClientWithRefresh
|
||||
func (mr *MockManagerMockRecorder) NewClientWithRefresh(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewClientWithRefresh", reflect.TypeOf((*MockManager)(nil).NewClientWithRefresh), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// ReportBug mocks base method
|
||||
func (m *MockManager) ReportBug(arg0 context.Context, arg1 pmapi.ReportBugReq) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReportBug", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ReportBug indicates an expected call of ReportBug
|
||||
func (mr *MockManagerMockRecorder) ReportBug(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportBug", reflect.TypeOf((*MockManager)(nil).ReportBug), arg0, arg1)
|
||||
}
|
||||
|
||||
// SendSimpleMetric mocks base method
|
||||
func (m *MockManager) SendSimpleMetric(arg0 context.Context, arg1, arg2, arg3 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SendSimpleMetric", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SendSimpleMetric indicates an expected call of SendSimpleMetric
|
||||
func (mr *MockManagerMockRecorder) SendSimpleMetric(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendSimpleMetric", reflect.TypeOf((*MockManager)(nil).SendSimpleMetric), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// SetCookieJar mocks base method
|
||||
func (m *MockManager) SetCookieJar(arg0 http.CookieJar) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetCookieJar", arg0)
|
||||
}
|
||||
|
||||
// SetCookieJar indicates an expected call of SetCookieJar
|
||||
func (mr *MockManagerMockRecorder) SetCookieJar(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCookieJar", reflect.TypeOf((*MockManager)(nil).SetCookieJar), arg0)
|
||||
}
|
||||
|
||||
// SetLogger mocks base method
|
||||
func (m *MockManager) SetLogger(arg0 resty.Logger) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetLogger", arg0)
|
||||
}
|
||||
|
||||
// SetLogger indicates an expected call of SetLogger
|
||||
func (mr *MockManagerMockRecorder) SetLogger(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLogger", reflect.TypeOf((*MockManager)(nil).SetLogger), arg0)
|
||||
}
|
||||
|
||||
// SetRetryCount mocks base method
|
||||
func (m *MockManager) SetRetryCount(arg0 int) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetRetryCount", arg0)
|
||||
}
|
||||
|
||||
// SetRetryCount indicates an expected call of SetRetryCount
|
||||
func (mr *MockManagerMockRecorder) SetRetryCount(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRetryCount", reflect.TypeOf((*MockManager)(nil).SetRetryCount), arg0)
|
||||
}
|
||||
|
||||
// SetTransport mocks base method
|
||||
func (m *MockManager) SetTransport(arg0 http.RoundTripper) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetTransport", arg0)
|
||||
}
|
||||
|
||||
// SetTransport indicates an expected call of SetTransport
|
||||
func (mr *MockManagerMockRecorder) SetTransport(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTransport", reflect.TypeOf((*MockManager)(nil).SetTransport), arg0)
|
||||
}
|
||||
|
||||
23
pkg/pmapi/observer.go
Normal file
23
pkg/pmapi/observer.go
Normal file
@ -0,0 +1,23 @@
|
||||
package pmapi
|
||||
|
||||
type ConnectionObserver interface {
|
||||
OnDown()
|
||||
OnUp()
|
||||
}
|
||||
|
||||
type observer struct {
|
||||
onDown, onUp func()
|
||||
}
|
||||
|
||||
// NewConnectionObserver is a helper function to create a new connection observer from two callbacks.
|
||||
// It doesn't need to be used; anything which implements the ConnectionObserver interface can be an observer.
|
||||
func NewConnectionObserver(onDown, onUp func()) ConnectionObserver {
|
||||
return &observer{
|
||||
onDown: onDown,
|
||||
onUp: onUp,
|
||||
}
|
||||
}
|
||||
|
||||
func (o observer) OnDown() { o.onDown() }
|
||||
|
||||
func (o observer) OnUp() { o.onUp() }
|
||||
25
pkg/pmapi/out
Normal file
25
pkg/pmapi/out
Normal file
@ -0,0 +1,25 @@
|
||||
-- addresses.go
|
||||
-- attachments.go
|
||||
-- auth.go
|
||||
-- contacts.go
|
||||
-- events.go
|
||||
-- import.go
|
||||
-- key.go
|
||||
-- keyring.go
|
||||
-- labels.go
|
||||
-- manager_auth.go
|
||||
-- manager_download.go
|
||||
-- manager.go
|
||||
-- manager_metrics.go
|
||||
-- manager_ping.go
|
||||
-- manager_report.go
|
||||
-- manager_report_types.go
|
||||
-- manager_types.go
|
||||
-- message_send.go
|
||||
-- messages.go
|
||||
-- metrics.go
|
||||
-- observer.go
|
||||
-- passwords.go
|
||||
-- settings.go
|
||||
-- users.go
|
||||
-- utils.go
|
||||
15
pkg/pmapi/paging.go
Normal file
15
pkg/pmapi/paging.go
Normal file
@ -0,0 +1,15 @@
|
||||
package pmapi
|
||||
|
||||
const defaultPageSize = 100
|
||||
|
||||
func doPaged(elements []string, pageSize int, fn func([]string) error) error {
|
||||
for len(elements) > pageSize {
|
||||
if err := fn(elements[:pageSize]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elements = elements[pageSize:]
|
||||
}
|
||||
|
||||
return fn(elements)
|
||||
}
|
||||
@ -19,29 +19,30 @@ package pmapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"github.com/jameskeane/bcrypt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func HashMailboxPassword(password, salt string) (hashedPassword string, err error) {
|
||||
func HashMailboxPassword(password, salt string) ([]byte, error) {
|
||||
if salt == "" {
|
||||
hashedPassword = password
|
||||
return
|
||||
return []byte(password), nil
|
||||
}
|
||||
|
||||
decodedSalt, err := base64.StdEncoding.DecodeString(salt)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, errors.Wrap(err, "failed to decode salt")
|
||||
}
|
||||
|
||||
encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(decodedSalt)
|
||||
hashResult, err := bcrypt.Hash(password, "$2y$10$"+encodedSalt)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, errors.Wrap(err, "failed to bcrypt-hash password")
|
||||
}
|
||||
|
||||
if len(hashResult) != 60 {
|
||||
err = errors.New("pmapi: invalid mailbox password hash")
|
||||
return
|
||||
return nil, errors.New("pmapi: invalid mailbox password hash")
|
||||
}
|
||||
hashedPassword = hashResult[len(hashResult)-31:]
|
||||
return
|
||||
|
||||
return []byte(hashResult[len(hashResult)-31:]), nil
|
||||
}
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type pinChecker struct {
|
||||
trustedPins []string
|
||||
}
|
||||
|
||||
type sentReport struct {
|
||||
r tlsReport
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newPinChecker(trustedPins []string) *pinChecker {
|
||||
return &pinChecker{
|
||||
trustedPins: trustedPins,
|
||||
}
|
||||
}
|
||||
|
||||
// checkCertificate returns whether the connection presents a known TLS certificate.
|
||||
func (p *pinChecker) checkCertificate(conn net.Conn) error {
|
||||
tlsConn, ok := conn.(*tls.Conn)
|
||||
if !ok {
|
||||
return errors.New("connection is not a TLS connection")
|
||||
}
|
||||
|
||||
connState := tlsConn.ConnectionState()
|
||||
|
||||
for _, peerCert := range connState.PeerCertificates {
|
||||
fingerprint := certFingerprint(peerCert)
|
||||
|
||||
for _, pin := range p.trustedPins {
|
||||
if pin == fingerprint {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ErrTLSMismatch
|
||||
}
|
||||
|
||||
func certFingerprint(cert *x509.Certificate) string {
|
||||
hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return fmt.Sprintf(`pin-sha256=%q`, base64.StdEncoding.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
type clientInfoProvider interface {
|
||||
GetAppVersion() string
|
||||
GetUserAgent() string
|
||||
}
|
||||
|
||||
type tlsReporter struct {
|
||||
cm clientInfoProvider
|
||||
p *pinChecker
|
||||
sentReports []sentReport
|
||||
}
|
||||
|
||||
func newTLSReporter(p *pinChecker, cm clientInfoProvider) *tlsReporter {
|
||||
return &tlsReporter{
|
||||
cm: cm,
|
||||
p: p,
|
||||
}
|
||||
}
|
||||
|
||||
// reportCertIssue reports a TLS key mismatch.
|
||||
func (r *tlsReporter) reportCertIssue(remoteURI, host, port string, connState tls.ConnectionState) {
|
||||
var certChain []string
|
||||
|
||||
if len(connState.VerifiedChains) > 0 {
|
||||
certChain = marshalCert7468(connState.VerifiedChains[len(connState.VerifiedChains)-1])
|
||||
} else {
|
||||
certChain = marshalCert7468(connState.PeerCertificates)
|
||||
}
|
||||
|
||||
appVersion := r.cm.GetAppVersion()
|
||||
userAgent := r.cm.GetUserAgent()
|
||||
|
||||
report := newTLSReport(host, port, connState.ServerName, certChain, r.p.trustedPins, appVersion)
|
||||
|
||||
if !r.hasRecentlySentReport(report) {
|
||||
r.recordReport(report)
|
||||
go report.sendReport(remoteURI, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// hasRecentlySentReport returns whether the report was already sent within the last 24 hours.
|
||||
func (r *tlsReporter) hasRecentlySentReport(report tlsReport) bool {
|
||||
var validReports []sentReport
|
||||
|
||||
for _, r := range r.sentReports {
|
||||
if time.Since(r.t) < 24*time.Hour {
|
||||
validReports = append(validReports, r)
|
||||
}
|
||||
}
|
||||
|
||||
r.sentReports = validReports
|
||||
|
||||
for _, r := range r.sentReports {
|
||||
if cmp.Equal(report, r.r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// recordReport records the given report and the current time so we can check whether we recently sent this report.
|
||||
func (r *tlsReporter) recordReport(report tlsReport) {
|
||||
r.sentReports = append(r.sentReports, sentReport{r: report, t: time.Now()})
|
||||
}
|
||||
|
||||
func marshalCert7468(certs []*x509.Certificate) (pemCerts []string) {
|
||||
var buffer bytes.Buffer
|
||||
for _, cert := range certs {
|
||||
if err := pem.Encode(&buffer, &pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
}); err != nil {
|
||||
logrus.WithField("pkg", "pmapi/tls-pinning").WithError(err).Error("Failed to encode TLS certificate")
|
||||
}
|
||||
pemCerts = append(pemCerts, buffer.String())
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
return pemCerts
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type fakeClientInfoProvider struct {
|
||||
version, useragent string
|
||||
}
|
||||
|
||||
func (c *fakeClientInfoProvider) GetAppVersion() string {
|
||||
return c.version
|
||||
}
|
||||
|
||||
func (c *fakeClientInfoProvider) GetUserAgent() string {
|
||||
return c.useragent
|
||||
}
|
||||
|
||||
func TestPinCheckerDoubleReport(t *testing.T) {
|
||||
reportCounter := 0
|
||||
|
||||
reportServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reportCounter++
|
||||
}))
|
||||
|
||||
r := newTLSReporter(newPinChecker(TrustedAPIPins), &fakeClientInfoProvider{version: "3", useragent: "useragent"})
|
||||
|
||||
// Report the same issue many times.
|
||||
for i := 0; i < 10; i++ {
|
||||
r.reportCertIssue(reportServer.URL, "myhost", "443", tls.ConnectionState{})
|
||||
}
|
||||
|
||||
// We should only report once.
|
||||
assert.Eventually(t, func() bool {
|
||||
return reportCounter == 1
|
||||
}, time.Second, time.Millisecond)
|
||||
|
||||
// If we then report something else many times.
|
||||
for i := 0; i < 10; i++ {
|
||||
r.reportCertIssue(reportServer.URL, "anotherhost", "443", tls.ConnectionState{})
|
||||
}
|
||||
|
||||
// We should get a second report.
|
||||
assert.Eventually(t, func() bool {
|
||||
return reportCounter == 2
|
||||
}, time.Second, time.Millisecond)
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
// DANGEROUSLYSetUID SHOULD NOT be used!!! This is only for testing purposes.
|
||||
func (s *Auth) DANGEROUSLYSetUID(uid string) {
|
||||
s.uid = uid
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyUseDuration = 24 * time.Hour
|
||||
proxyLookupWait = 5 * time.Second
|
||||
proxyCacheRefreshTimeout = 20 * time.Second
|
||||
proxyDoHTimeout = 20 * time.Second
|
||||
proxyCanReachTimeout = 20 * time.Second
|
||||
proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
)
|
||||
|
||||
var dohProviders = []string{ //nolint[gochecknoglobals]
|
||||
"https://dns11.quad9.net/dns-query",
|
||||
"https://dns.google/dns-query",
|
||||
}
|
||||
|
||||
// proxyProvider manages known proxies.
|
||||
type proxyProvider struct {
|
||||
// dohLookup is used to look up the given query at the given DoH provider, returning the TXT records>
|
||||
dohLookup func(ctx context.Context, query, provider string) (urls []string, err error)
|
||||
|
||||
providers []string // List of known doh providers.
|
||||
query string // The query string used to find proxies.
|
||||
proxyCache []string // All known proxies, cached in case DoH providers are unreachable.
|
||||
|
||||
cacheRefreshTimeout time.Duration
|
||||
dohTimeout time.Duration
|
||||
canReachTimeout time.Duration
|
||||
|
||||
lastLookup time.Time // The time at which we last attempted to find a proxy.
|
||||
}
|
||||
|
||||
// newProxyProvider creates a new proxyProvider that queries the given DoH providers
|
||||
// to retrieve DNS records for the given query string.
|
||||
func newProxyProvider(providers []string, query string) (p *proxyProvider) { // nolint[unparam]
|
||||
p = &proxyProvider{
|
||||
providers: providers,
|
||||
query: query,
|
||||
cacheRefreshTimeout: proxyCacheRefreshTimeout,
|
||||
dohTimeout: proxyDoHTimeout,
|
||||
canReachTimeout: proxyCanReachTimeout,
|
||||
}
|
||||
|
||||
// Use the default DNS lookup method; this can be overridden if necessary.
|
||||
p.dohLookup = p.defaultDoHLookup
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// findReachableServer returns a working API server (either proxy or standard API).
|
||||
func (p *proxyProvider) findReachableServer() (proxy string, err error) {
|
||||
logrus.Debug("Trying to find a reachable server")
|
||||
|
||||
if time.Now().Before(p.lastLookup.Add(proxyLookupWait)) {
|
||||
return "", errors.New("not looking for a proxy, too soon")
|
||||
}
|
||||
|
||||
p.lastLookup = time.Now()
|
||||
|
||||
// We use a waitgroup to wait for both
|
||||
// a) the check whether the API is reachable, and
|
||||
// b) the DoH queries.
|
||||
// This is because the Alternative Routes v2 spec says:
|
||||
// Call the GET /test/ping route on normal API domain (same time as DoH requests and wait until all have finished)
|
||||
var wg sync.WaitGroup
|
||||
var apiReachable bool
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
apiReachable = p.canReach(rootURL)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err = p.refreshProxyCache()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if apiReachable {
|
||||
proxy = rootURL
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, url := range p.proxyCache {
|
||||
if p.canReach(url) {
|
||||
proxy = url
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("no reachable server could be found")
|
||||
}
|
||||
|
||||
// refreshProxyCache loads the latest proxies from the known providers.
|
||||
// If the process takes longer than proxyCacheRefreshTimeout, an error is returned.
|
||||
func (p *proxyProvider) refreshProxyCache() error {
|
||||
logrus.Info("Refreshing proxy cache")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.cacheRefreshTimeout)
|
||||
defer cancel()
|
||||
|
||||
resultChan := make(chan []string)
|
||||
|
||||
go func() {
|
||||
for _, provider := range p.providers {
|
||||
if proxies, err := p.dohLookup(ctx, p.query, provider); err == nil {
|
||||
resultChan <- proxies
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
p.proxyCache = result
|
||||
return nil
|
||||
|
||||
case <-ctx.Done():
|
||||
return errors.New("timed out while refreshing proxy cache")
|
||||
}
|
||||
}
|
||||
|
||||
// canReach returns whether we can reach the given url.
|
||||
func (p *proxyProvider) canReach(url string) bool {
|
||||
logrus.WithField("url", url).Debug("Trying to ping proxy")
|
||||
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
|
||||
dialer := NewPinningTLSDialer(NewBasicTLSDialer())
|
||||
|
||||
pinger := resty.New().
|
||||
SetHostURL(url).
|
||||
SetTimeout(p.canReachTimeout).
|
||||
SetTransport(CreateTransportWithDialer(dialer))
|
||||
|
||||
if _, err := pinger.R().Get("/tests/ping"); err != nil {
|
||||
logrus.WithField("proxy", url).WithError(err).Warn("Failed to ping proxy")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// defaultDoHLookup is the default implementation of the proxy manager's DoH lookup.
|
||||
// It looks up DNS TXT records for the given query URL using the given DoH provider.
|
||||
// It returns a list of all found TXT records.
|
||||
// If the whole process takes more than proxyDoHTimeout then an error is returned.
|
||||
func (p *proxyProvider) defaultDoHLookup(ctx context.Context, query, dohProvider string) (data []string, err error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, p.dohTimeout)
|
||||
defer cancel()
|
||||
|
||||
dataChan, errChan := make(chan []string), make(chan error)
|
||||
|
||||
go func() {
|
||||
// Build new DNS request in RFC1035 format.
|
||||
dnsRequest := new(dns.Msg).SetQuestion(dns.Fqdn(query), dns.TypeTXT)
|
||||
|
||||
// Pack the DNS request message into wire format.
|
||||
rawRequest, err := dnsRequest.Pack()
|
||||
if err != nil {
|
||||
errChan <- errors.Wrap(err, "failed to pack DNS request")
|
||||
return
|
||||
}
|
||||
|
||||
// Encode wire-format DNS request message as base64url (RFC4648) without padding chars.
|
||||
encodedRequest := base64.RawURLEncoding.EncodeToString(rawRequest)
|
||||
|
||||
// Make DoH request to the given DoH provider.
|
||||
rawResponse, err := resty.New().R().SetContext(ctx).SetQueryParam("dns", encodedRequest).Get(dohProvider)
|
||||
if err != nil {
|
||||
errChan <- errors.Wrap(err, "failed to make DoH request")
|
||||
return
|
||||
}
|
||||
|
||||
// Unpack the DNS response.
|
||||
dnsResponse := new(dns.Msg)
|
||||
if err = dnsResponse.Unpack(rawResponse.Body()); err != nil {
|
||||
errChan <- errors.Wrap(err, "failed to unpack DNS response")
|
||||
return
|
||||
}
|
||||
|
||||
// Pick out the TXT answers.
|
||||
for _, answer := range dnsResponse.Answer {
|
||||
if t, ok := answer.(*dns.TXT); ok {
|
||||
data = append(data, t.Txt...)
|
||||
}
|
||||
}
|
||||
|
||||
dataChan <- data
|
||||
}()
|
||||
|
||||
select {
|
||||
case data = <-dataChan:
|
||||
logrus.WithField("data", data).Info("Received TXT records")
|
||||
return
|
||||
|
||||
case err = <-errChan:
|
||||
logrus.WithField("provider", dohProvider).WithError(err).Error("Failed to query DNS records")
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
logrus.WithField("provider", dohProvider).Error("Timed out querying DNS records")
|
||||
return []string{}, errors.New("timed out querying DNS records")
|
||||
}
|
||||
}
|
||||
@ -1,468 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
TestQuad9Provider = "https://dns11.quad9.net/dns-query"
|
||||
TestGoogleProvider = "https://dns.google/dns-query"
|
||||
)
|
||||
|
||||
// getTrustedServer returns a server and sets its public key as one of the pinned ones.
|
||||
func getTrustedServer() *httptest.Server {
|
||||
return getTrustedServerWithHandler(
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
// Do nothing.
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func getTrustedServerWithHandler(handler http.HandlerFunc) *httptest.Server {
|
||||
proxy := httptest.NewTLSServer(handler)
|
||||
|
||||
pin := certFingerprint(proxy.Certificate())
|
||||
TrustedAPIPins = append(TrustedAPIPins, pin)
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
// server.crt data.
|
||||
const servercrt = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE5TCCA82gAwIBAgIJAKsmhcMFGfGcMA0GCSqGSIb3DQEBCwUAMIGsMQswCQYD
|
||||
VQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzARBgNVBAcMClJhbmRvbUNp
|
||||
dHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEfMB0GA1UECwwWUmFuZG9t
|
||||
T3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYRaGVsbG9AZXhhbXBsZS5j
|
||||
b20xEjAQBgNVBAMMCTEyNy4wLjAuMTAeFw0yMDA0MjQxMzI3MzdaFw0yMTA5MDYx
|
||||
MzI3MzdaMIGsMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzAR
|
||||
BgNVBAcMClJhbmRvbUNpdHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEf
|
||||
MB0GA1UECwwWUmFuZG9tT3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYR
|
||||
aGVsbG9AZXhhbXBsZS5jb20xEjAQBgNVBAMMCTEyNy4wLjAuMTCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBANAnYyqhosWwNzGjBwSwmDUINOaPs4TSTgKt
|
||||
r6CE01atxAWzWUCyYqnQ4fPe5q2tx5t/VrmnTNpzycammKJszGLlmj9DFxSiYVw2
|
||||
pTTK3DBWFkfTwxq98mM7wMnCWy1T2L2pmuYjnd7Pa6pQa9OHYoJwRzlIl2Q3YVdM
|
||||
GIBDbkW728A1dcelkIdFpv3r3ayTZv01vU8JMXd4PLHwXU0x0hHlH52+kx+9Ndru
|
||||
rdqqV6LqVfNlSR1jFZkwLBBqvh3XrJRD9Q01EAX6m+ufZ0yq8mK9ifMRtwQet10c
|
||||
kKMnx63MwvxDFmqrBj4HMtIRUpK+LBDs1ke7DvS0eLqaojWl28ECAwEAAaOCAQYw
|
||||
ggECMIHLBgNVHSMEgcMwgcChgbKkga8wgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQI
|
||||
DAtSYW5kb21TdGF0ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFu
|
||||
ZG9tT3JnYW5pemF0aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0
|
||||
MSAwHgYJKoZIhvcNAQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJMTI3
|
||||
LjAuMC4xggkAvCxbs152YckwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0R
|
||||
BBMwEYIJMTI3LjAuMC4xhwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQAC7ZycZMZ5
|
||||
L+cjIpwSj0cemLkVD+kcFUCkI7ket5gbX1PmavmnpuFl9Sru0eJ5wyJ+97MQElPA
|
||||
CNFgXoX7DbJWkcd/LSksvZoJnpc1sTqFKMWFmOUxmUD62lCacuhqE27ZTThQ/53P
|
||||
3doLa74rKzUqlPI8OL4R34FY2deL7t5l2KSnpf7CKNeF5bkinAsn6NBqyZs2KPmg
|
||||
yT1/POdlRewzGSqBTMdktNQ4vKSfdFjcfVeo8PSHBgbGXZ5KoHZ6R6DNJehEh27l
|
||||
z3OteROLGoii+w3OllLq6JATif2MDIbH0s/KjGjbXSSGbM/rZu5eBZm5/vksGAzc
|
||||
u53wgIhCJGuX
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
const serverkey = `
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDQJ2MqoaLFsDcx
|
||||
owcEsJg1CDTmj7OE0k4Cra+ghNNWrcQFs1lAsmKp0OHz3uatrcebf1a5p0zac8nG
|
||||
ppiibMxi5Zo/QxcUomFcNqU0ytwwVhZH08MavfJjO8DJwlstU9i9qZrmI53ez2uq
|
||||
UGvTh2KCcEc5SJdkN2FXTBiAQ25Fu9vANXXHpZCHRab9692sk2b9Nb1PCTF3eDyx
|
||||
8F1NMdIR5R+dvpMfvTXa7q3aqlei6lXzZUkdYxWZMCwQar4d16yUQ/UNNRAF+pvr
|
||||
n2dMqvJivYnzEbcEHrddHJCjJ8etzML8QxZqqwY+BzLSEVKSviwQ7NZHuw70tHi6
|
||||
mqI1pdvBAgMBAAECggEAOqqPOYm63arPs462QK0hCPlaJ41i1FGNqRWYxU4KXoi1
|
||||
EcI9qo1cX24+8MPnEhZDhuD56XNsprkxqmpz5Htzk4AQ3DmlfKxTcnD4WQu/yWPJ
|
||||
/c6CU7wrX6qMqJC9r+XM1Y/C15A8Q3sEZkkqSsECk67fdBawjI9LQRZyZVwb7U0F
|
||||
qtvbKM7VQA6hrgdSmXWJ+spp5yymVFF22Ssz31SSbCI93bnp3mukRCKWdRmA9pmT
|
||||
VXa0HzJ5p70WC+Se9nA/1riWGKt4HCmjVeEtZuiwaUTlXDSeYpu2e4QrX1OnUXBu
|
||||
Z7yfviTqA8o7KfiA6urumFbAMJcibxkWJoWacc5tTQKBgQD39ZdtNz8B6XJy7f5h
|
||||
bo9Ag9OrkVX+HITQyWKpcCDba9SuIX3/F++2AK4oeJ3aHKMJWiP19hQvGS1xE67X
|
||||
TKejOsQxORn6nAYQpFd3AOBOtKAC+VQITBqlfq2ukGmvcQ1O31hMOFbZagFA5cpU
|
||||
LYb9VVDsZzhM7CccIn/EGEZjgwKBgQDW51rUA2S9naV/iEGhw1tuhoQ5OADD/n8f
|
||||
pPIkbGxmACDaX/7jt+UwlDU0EsI+aBlJUDqGiEZ5z3UPmaSJUdfRCeJEdKIe1GLm
|
||||
nqF3sF6Aq+S/79v/wKYn+MHcoiWog5n3McLzZ3+0rwrhMREjE2eWPwVHz/jJIFP3
|
||||
Pp3+UZVsawKBgB4Az5PdjXgzwS968L7lW9wYl3I5Iciftsp0s8WA1dj3EUMItnA5
|
||||
ez3wkyI+hgswT+H/0D4gyoxwZXk7Qnq2wcoUgEzcdfJHEszMtfCmYH3liT8S4EIo
|
||||
w0inLWjj/IXIDi4vBEYkww2HsCMkKvlIkP7yZdpVGxDjuk/DNOaLcWj1AoGAXuyK
|
||||
PiPRl7/Onmp9MwqrlEJunSeTjv8W/89H9ba+mr9rw4mreMJ9xdtxNLMkgZRRtwRt
|
||||
FYeUObHdLyradp1kCr2m6D3sblm55cwj3k5VL9i9jdpQ/sMFoZpLZz1oDOs0Uu/0
|
||||
ALeyvQikcZvOygOEOeVUW8gNSCmzbP6HoxI+QkkCgYBCI6oL4GPcPPqzd+2djbOD
|
||||
z3rVUyHzYc1KUcBixK/uaRQKM886k4CL8/GvbHHI/yoZ7xWJGnBi59DtpqnGTZJ2
|
||||
FDJwYIlQKhZmsyVcZu/4smsaejGnHn/liksVlgesSwCtOrsd2AC8fBXSyrTWJx8o
|
||||
vwRMog6lPhlRhHh/FZ43Cg==
|
||||
-----END PRIVATE KEY-----
|
||||
`
|
||||
|
||||
// getUntrustedServer returns a server but it doesn't add its public key to the list of pinned ones.
|
||||
func getUntrustedServer() *httptest.Server {
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
cert, err := tls.X509KeyPair([]byte(servercrt), []byte(serverkey))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
|
||||
server.StartTLS()
|
||||
return server
|
||||
}
|
||||
|
||||
// closeServer closes the given server. If it is a trusted server, its cert is removed from the trusted public keys.
|
||||
func closeServer(server *httptest.Server) {
|
||||
pin := certFingerprint(server.Certificate())
|
||||
|
||||
for i := range TrustedAPIPins {
|
||||
if TrustedAPIPins[i] == pin {
|
||||
TrustedAPIPins = append(TrustedAPIPins[:i], TrustedAPIPins[i:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
server.Close()
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := getTrustedServer()
|
||||
defer closeServer(proxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy.URL}, nil }
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy.URL, url)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_ChooseReachableProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
reachableProxy := getTrustedServer()
|
||||
defer closeServer(reachableProxy)
|
||||
|
||||
// We actually close the unreachable proxy straight away rather than deferring the closure.
|
||||
unreachableProxy := getTrustedServer()
|
||||
closeServer(unreachableProxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) {
|
||||
return []string{reachableProxy.URL, unreachableProxy.URL}, nil
|
||||
}
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, reachableProxy.URL, url)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_ChooseTrustedProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
trustedProxy := getTrustedServer()
|
||||
defer closeServer(trustedProxy)
|
||||
|
||||
untrustedProxy := getUntrustedServer()
|
||||
defer closeServer(untrustedProxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) {
|
||||
return []string{untrustedProxy.URL, trustedProxy.URL}, nil
|
||||
}
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trustedProxy.URL, url)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_FailIfNoneReachable(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
unreachableProxy1 := getTrustedServer()
|
||||
closeServer(unreachableProxy1)
|
||||
|
||||
unreachableProxy2 := getTrustedServer()
|
||||
closeServer(unreachableProxy2)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) {
|
||||
return []string{unreachableProxy1.URL, unreachableProxy2.URL}, nil
|
||||
}
|
||||
|
||||
_, err := p.findReachableServer()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_FailIfNoneTrusted(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
untrustedProxy1 := getUntrustedServer()
|
||||
defer closeServer(untrustedProxy1)
|
||||
|
||||
untrustedProxy2 := getUntrustedServer()
|
||||
defer closeServer(untrustedProxy2)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) {
|
||||
return []string{untrustedProxy1.URL, untrustedProxy2.URL}, nil
|
||||
}
|
||||
|
||||
_, err := p.findReachableServer()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_RefreshCacheTimeout(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.cacheRefreshTimeout = 1 * time.Second
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil }
|
||||
|
||||
// We should fail to refresh the proxy cache because the doh provider
|
||||
// takes 2 seconds to respond but we timeout after just 1 second.
|
||||
_, err := p.findReachableServer()
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyProvider_FindProxy_CanReachTimeout(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
slowProxy := getTrustedServerWithHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
}))
|
||||
defer closeServer(slowProxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
p.canReachTimeout = 1 * time.Second
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{slowProxy.URL}, nil }
|
||||
|
||||
// We should fail to reach the returned proxy because it takes 2 seconds
|
||||
// to reach it and we only allow 1.
|
||||
_, err := p.findReachableServer()
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyProvider_UseProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
|
||||
trustedProxy := getTrustedServer()
|
||||
defer closeServer(trustedProxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
cm.proxyProvider = p
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil }
|
||||
url, err := cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trustedProxy.URL, url)
|
||||
require.Equal(t, trustedProxy.URL, cm.getHost())
|
||||
}
|
||||
|
||||
func TestProxyProvider_UseProxy_MultipleTimes(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
|
||||
proxy1 := getTrustedServer()
|
||||
defer closeServer(proxy1)
|
||||
proxy2 := getTrustedServer()
|
||||
defer closeServer(proxy2)
|
||||
proxy3 := getTrustedServer()
|
||||
defer closeServer(proxy3)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
cm.proxyProvider = p
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy1.URL}, nil }
|
||||
url, err := cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy1.URL, url)
|
||||
require.Equal(t, proxy1.URL, cm.getHost())
|
||||
|
||||
// Have to wait so as to not get rejected.
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy2.URL}, nil }
|
||||
url, err = cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy2.URL, url)
|
||||
require.Equal(t, proxy2.URL, cm.getHost())
|
||||
|
||||
// Have to wait so as to not get rejected.
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy3.URL}, nil }
|
||||
url, err = cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy3.URL, url)
|
||||
require.Equal(t, proxy3.URL, cm.getHost())
|
||||
}
|
||||
|
||||
func TestProxyProvider_UseProxy_RevertAfterTime(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
|
||||
trustedProxy := getTrustedServer()
|
||||
defer closeServer(trustedProxy)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
cm.proxyProvider = p
|
||||
cm.proxyUseDuration = time.Second
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil }
|
||||
url, err := cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trustedProxy.URL, url)
|
||||
require.Equal(t, trustedProxy.URL, cm.getHost())
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
require.Equal(t, rootURL, cm.getHost())
|
||||
}
|
||||
|
||||
func TestProxyProvider_UseProxy_RevertIfProxyStopsWorkingAndOriginalAPIIsReachable(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
|
||||
trustedProxy := getTrustedServer()
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
cm.proxyProvider = p
|
||||
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{trustedProxy.URL}, nil }
|
||||
url, err := cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trustedProxy.URL, url)
|
||||
require.Equal(t, trustedProxy.URL, cm.getHost())
|
||||
|
||||
// Simulate that the proxy stops working and that the standard api is reachable again.
|
||||
closeServer(trustedProxy)
|
||||
unblockAPI()
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
// We should now find the original API URL if it is working again.
|
||||
// The error should be ErrAPINotReachable because the connection dropped intermittently but
|
||||
// the original API is now reachable (see Alternative-Routing-v2 spec for details).
|
||||
url, err = cm.switchToReachableServer()
|
||||
require.Error(t, err)
|
||||
require.Equal(t, rootURL, url)
|
||||
require.Equal(t, rootURL, cm.getHost())
|
||||
}
|
||||
|
||||
func TestProxyProvider_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBlocked(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
|
||||
// proxy1 is closed later in this test so we don't defer it here.
|
||||
proxy1 := getTrustedServer()
|
||||
|
||||
proxy2 := getTrustedServer()
|
||||
defer closeServer(proxy2)
|
||||
|
||||
p := newProxyProvider([]string{"not used"}, "not used")
|
||||
cm.proxyProvider = p
|
||||
|
||||
// Find a proxy.
|
||||
p.dohLookup = func(ctx context.Context, q, p string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil }
|
||||
url, err := cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy1.URL, url)
|
||||
require.Equal(t, proxy1.URL, cm.getHost())
|
||||
|
||||
// Have to wait so as to not get rejected.
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
// The proxy stops working and the protonmail API is still blocked.
|
||||
proxy1.Close()
|
||||
|
||||
// Should switch to the second proxy because both the first proxy and the protonmail API are blocked.
|
||||
url, err = cm.switchToReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy2.URL, url)
|
||||
require.Equal(t, proxy2.URL, cm.getHost())
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_Quad9(t *testing.T) {
|
||||
p := newProxyProvider([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestQuad9Provider)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_Google(t *testing.T) {
|
||||
p := newProxyProvider([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestGoogleProvider)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_FindProxy(t *testing.T) {
|
||||
p := newProxyProvider([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, url)
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) {
|
||||
p := newProxyProvider([]string{"https://unreachable", TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, url)
|
||||
}
|
||||
|
||||
// testAPIURLBackup is used to hold the globalOriginalURL because we clear it for test purposes and need to restore it.
|
||||
var testAPIURLBackup = rootURL
|
||||
|
||||
// blockAPI prevents tests from reaching the standard API, forcing them to find a proxy.
|
||||
func blockAPI() {
|
||||
rootURL = ""
|
||||
}
|
||||
|
||||
// unblockAPI allow tests to reach the standard API again.
|
||||
func unblockAPI() {
|
||||
rootURL = testAPIURLBackup
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewRequest creates a new request.
|
||||
func (c *client) NewRequest(method, path string, body io.Reader) (*http.Request, error) {
|
||||
return http.NewRequest(method, c.cm.GetRootURL()+path, body)
|
||||
}
|
||||
|
||||
// NewJSONRequest create a new JSON request.
|
||||
func (c *client) NewJSONRequest(method, path string, body interface{}) (*http.Request, error) {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
req, err := c.NewRequest(method, path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type MultipartWriter struct {
|
||||
*multipart.Writer
|
||||
|
||||
c io.Closer
|
||||
}
|
||||
|
||||
func (w *MultipartWriter) Close() error {
|
||||
if err := w.Writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.c.Close()
|
||||
}
|
||||
|
||||
// NewMultipartRequest creates a new multipart request.
|
||||
//
|
||||
// The multipart request is written as long as it is sent to the API. That means
|
||||
// that writing the request and sending it MUST be done in parallel. If the
|
||||
// request fails, subsequent writes to the multipart writer will fail with an
|
||||
// io.ErrClosedPipe error.
|
||||
func (c *client) NewMultipartRequest(method, path string) (req *http.Request, w *MultipartWriter, err error) {
|
||||
// The pipe will connect the multipart writer and the HTTP request body.
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// pw needs to be closed once the multipart writer is closed.
|
||||
w = &MultipartWriter{
|
||||
multipart.NewWriter(pw),
|
||||
pw,
|
||||
}
|
||||
|
||||
req, err = c.NewRequest(method, path, pr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", w.FormDataContentType())
|
||||
return
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Common response codes.
|
||||
const (
|
||||
CodeOk = 1000
|
||||
)
|
||||
|
||||
// Res is an API response.
|
||||
type Res struct {
|
||||
// The response code is the code from the body JSON. It's still used,
|
||||
// but preference is to use HTTP status code instead for new changes.
|
||||
Code int
|
||||
StatusCode int
|
||||
|
||||
// The error, if there is any.
|
||||
*ResError
|
||||
}
|
||||
|
||||
// Err returns error if the response is an error. Otherwise, returns nil.
|
||||
func (res Res) Err() error {
|
||||
if res.Code == ForceUpgradeBadAppVersion {
|
||||
return ErrUpgradeApplication
|
||||
}
|
||||
|
||||
if res.Code == APIOffline {
|
||||
return ErrAPINotReachable
|
||||
}
|
||||
|
||||
if res.StatusCode == http.StatusUnprocessableEntity {
|
||||
return &ErrUnprocessableEntity{errors.New(res.Error)}
|
||||
}
|
||||
|
||||
if res.ResError == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Error{
|
||||
Code: res.Code,
|
||||
ErrorMessage: res.ResError.Error,
|
||||
}
|
||||
}
|
||||
|
||||
type ResError struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// Error is an API error.
|
||||
type Error struct {
|
||||
// The error code.
|
||||
Code int
|
||||
// The error message.
|
||||
ErrorMessage string `json:"Error"`
|
||||
}
|
||||
|
||||
func (err Error) Error() string {
|
||||
return err.ErrorMessage
|
||||
}
|
||||
82
pkg/pmapi/response.go
Normal file
82
pkg/pmapi/response.go
Normal file
@ -0,0 +1,82 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Code int
|
||||
Message string `json:"Error"`
|
||||
}
|
||||
|
||||
func (err Error) Error() string {
|
||||
return err.Message
|
||||
}
|
||||
|
||||
func catchAPIError(_ *resty.Client, res *resty.Response) error {
|
||||
if !res.IsError() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if apiErr, ok := res.Error().(*Error); ok {
|
||||
err = apiErr
|
||||
} else {
|
||||
err = errors.New(res.Status())
|
||||
}
|
||||
|
||||
switch res.StatusCode() {
|
||||
case http.StatusUnauthorized:
|
||||
return errors.Wrap(ErrUnauthorized, err.Error())
|
||||
|
||||
default:
|
||||
return errors.Wrap(ErrAPIFailure, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func catchRetryAfter(_ *resty.Client, res *resty.Response) (time.Duration, error) {
|
||||
if res.StatusCode() == http.StatusTooManyRequests {
|
||||
if after := res.Header().Get("Retry-After"); after != "" {
|
||||
seconds, err := strconv.Atoi(after)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func catchTooManyRequests(res *resty.Response, _ error) bool {
|
||||
return res.StatusCode() == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
func catchNoResponse(res *resty.Response, err error) bool {
|
||||
return res.RawResponse == nil && err != nil
|
||||
}
|
||||
|
||||
func catchProxyAvailable(res *resty.Response, err error) bool {
|
||||
/*
|
||||
if res.Request.Attempt < ... {
|
||||
return false
|
||||
}
|
||||
|
||||
if response is not empty {
|
||||
return false
|
||||
}
|
||||
|
||||
if proxy is available {
|
||||
return true
|
||||
}
|
||||
*/
|
||||
|
||||
return false
|
||||
}
|
||||
@ -22,7 +22,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@ -30,6 +29,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
@ -70,23 +70,21 @@ func Equals(tb testing.TB, exp, act interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServer is old function and should be replaced everywhere by newTestServerCallbacks.
|
||||
func newTestServer(h http.Handler) (*httptest.Server, *client) {
|
||||
s := httptest.NewServer(h)
|
||||
|
||||
serverURL, err := url.Parse(s.URL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
func newTestConfig(url string) Config {
|
||||
return Config{
|
||||
HostURL: url,
|
||||
AppVersion: "GoPMAPI_1.0.14",
|
||||
}
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
cm.host = serverURL.Host
|
||||
cm.scheme = serverURL.Scheme
|
||||
|
||||
return s, newTestClient(cm)
|
||||
}
|
||||
|
||||
func newTestServerCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.ResponseWriter, *http.Request) string) (func(), *client) {
|
||||
// newTestClient is old function and should be replaced everywhere by newTestServerCallbacks.
|
||||
func newTestClient(h http.Handler) (*httptest.Server, Client) {
|
||||
s := httptest.NewServer(h)
|
||||
|
||||
return s, newManager(newTestConfig(s.URL)).NewClient(testUID, testAccessToken, testRefreshToken, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func newTestClientCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.ResponseWriter, *http.Request) string) (func(), Client) {
|
||||
reqNum := 0
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
file = filepath.Base(file)
|
||||
@ -106,11 +104,6 @@ func newTestServerCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.Re
|
||||
}
|
||||
}))
|
||||
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
finish := func() {
|
||||
server.CloseClientConnections() // Closing without waiting for finishing requests.
|
||||
if reqNum != len(callbacks) {
|
||||
@ -122,11 +115,7 @@ func newTestServerCallbacks(tb testing.TB, callbacks ...func(testing.TB, http.Re
|
||||
}
|
||||
}
|
||||
|
||||
cm := newTestClientManager(testClientConfig)
|
||||
cm.host = serverURL.Host
|
||||
cm.scheme = serverURL.Scheme
|
||||
|
||||
return finish, newTestClient(cm)
|
||||
return finish, newManager(newTestConfig(server.URL)).NewClient(testUID, testAccessToken, testRefreshToken, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func checkMethodAndPath(r *http.Request, method, path string) error {
|
||||
|
||||
@ -17,51 +17,11 @@
|
||||
|
||||
package pmapi
|
||||
|
||||
type UserSettings struct {
|
||||
PasswordMode int
|
||||
Email struct {
|
||||
Value string
|
||||
Status int
|
||||
Notify int
|
||||
Reset int
|
||||
}
|
||||
Phone struct {
|
||||
Value string
|
||||
Status int
|
||||
Notify int
|
||||
Reset int
|
||||
}
|
||||
News int
|
||||
Locale string
|
||||
LogAuth string
|
||||
InvoiceText string
|
||||
TOTP int
|
||||
U2FKeys []struct {
|
||||
Label string
|
||||
KeyHandle string
|
||||
Compromised int
|
||||
}
|
||||
}
|
||||
import (
|
||||
"context"
|
||||
|
||||
// GetUserSettings gets general settings.
|
||||
func (c *client) GetUserSettings() (settings UserSettings, err error) {
|
||||
req, err := c.NewRequest("GET", "/settings", nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Res
|
||||
UserSettings UserSettings
|
||||
}
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return res.UserSettings, res.Err()
|
||||
}
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type MailSettings struct {
|
||||
DisplayName string
|
||||
@ -98,21 +58,16 @@ type MailSettings struct {
|
||||
}
|
||||
|
||||
// GetMailSettings gets contact details specified by contact ID.
|
||||
func (c *client) GetMailSettings() (settings MailSettings, err error) {
|
||||
req, err := c.NewRequest("GET", "/mail/v4/settings", nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) GetMailSettings(ctx context.Context) (settings MailSettings, err error) {
|
||||
var res struct {
|
||||
Res
|
||||
MailSettings MailSettings
|
||||
}
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/mail/v4/settings")
|
||||
}); err != nil {
|
||||
return MailSettings{}, err
|
||||
}
|
||||
|
||||
return res.MailSettings, res.Err()
|
||||
return res.MailSettings, nil
|
||||
}
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
// Copyright (c) 2021 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.Bridge.
|
||||
//
|
||||
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrTLSMismatch indicates that no TLS fingerprint match could be found.
|
||||
var ErrTLSMismatch = errors.New("no TLS fingerprint match found")
|
||||
|
||||
// TrustedAPIPins contains trusted public keys of the protonmail API and proxies.
|
||||
// NOTE: the proxy pins are the same for all proxy servers, guaranteed by infra team ;).
|
||||
var TrustedAPIPins = []string{ // nolint[gochecknoglobals]
|
||||
// api.protonmail.ch
|
||||
`pin-sha256="drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE="`, // current
|
||||
`pin-sha256="YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54="`, // hot backup
|
||||
`pin-sha256="AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="`, // cold backup
|
||||
|
||||
// protonmail.com
|
||||
`pin-sha256="8joiNBdqaYiQpKskgtkJsqRxF7zN0C0aqfi8DacknnI="`, // current
|
||||
`pin-sha256="JMI8yrbc6jB1FYGyyWRLFTmDNgIszrNEMGlgy972e7w="`, // hot backup
|
||||
`pin-sha256="Iu44zU84EOCZ9vx/vz67/MRVrxF1IO4i4NIa8ETwiIY="`, // cold backup
|
||||
|
||||
// proxies
|
||||
`pin-sha256="EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM="`, // main
|
||||
`pin-sha256="iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA="`, // backup 1
|
||||
`pin-sha256="MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA="`, // backup 2
|
||||
`pin-sha256="C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw="`, // backup 3
|
||||
}
|
||||
|
||||
// TLSReportURI is the address where TLS reports should be sent.
|
||||
const TLSReportURI = "https://reports.protonmail.ch/reports/tls"
|
||||
|
||||
// tlsReport is inspired by https://tools.ietf.org/html/rfc7469#section-3.
|
||||
// When a TLS key mismatch is detected, a tlsReport is posted to TLSReportURI.
|
||||
type tlsReport struct {
|
||||
// DateTime of observed pin validation in time.RFC3339 format.
|
||||
DateTime string `json:"date-time"`
|
||||
|
||||
// Hostname to which the UA made original request that failed pin validation.
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
// Port to which the UA made original request that failed pin validation.
|
||||
Port int `json:"port"`
|
||||
|
||||
// EffectiveExpirationDate for noted pins in time.RFC3339 format.
|
||||
EffectiveExpirationDate string `json:"effective-expiration-date"`
|
||||
|
||||
// IncludeSubdomains indicates whether or not the UA has noted the
|
||||
// includeSubDomains directive for the Known Pinned Host.
|
||||
IncludeSubdomains bool `json:"include-subdomains"`
|
||||
|
||||
// NotedHostname indicates the hostname that the UA noted when it noted
|
||||
// the Known Pinned Host. This field allows operators to understand why
|
||||
// Pin Validation was performed for, e.g., foo.example.com when the
|
||||
// noted Known Pinned Host was example.com with includeSubDomains set.
|
||||
NotedHostname string `json:"noted-hostname"`
|
||||
|
||||
// ServedCertificateChain is the certificate chain, as served by
|
||||
// the Known Pinned Host during TLS session setup. It is provided as an
|
||||
// array of strings; each string pem1, ... pemN is the Privacy-Enhanced
|
||||
// Mail (PEM) representation of each X.509 certificate as described in
|
||||
// [RFC7468].
|
||||
ServedCertificateChain []string `json:"served-certificate-chain"`
|
||||
|
||||
// ValidatedCertificateChain is the certificate chain, as
|
||||
// constructed by the UA during certificate chain verification. (This
|
||||
// may differ from the served-certificate-chain.) It is provided as an
|
||||
// array of strings; each string pem1, ... pemN is the PEM
|
||||
// representation of each X.509 certificate as described in [RFC7468].
|
||||
// UAs that build certificate chains in more than one way during the
|
||||
// validation process SHOULD send the last chain built. In this way,
|
||||
// they can avoid keeping too much state during the validation process.
|
||||
ValidatedCertificateChain []string `json:"validated-certificate-chain"`
|
||||
|
||||
// The known-pins are the Pins that the UA has noted for the Known
|
||||
// Pinned Host. They are provided as an array of strings with the
|
||||
// syntax: known-pin = token "=" quoted-string
|
||||
// e.g.:
|
||||
// ```
|
||||
// "known-pins": [
|
||||
// 'pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="',
|
||||
// "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
|
||||
// ]
|
||||
// ```
|
||||
KnownPins []string `json:"known-pins"`
|
||||
|
||||
// AppVersion is used to set `x-pm-appversion` json format from datatheorem/TrustKit.
|
||||
AppVersion string `json:"app-version"`
|
||||
}
|
||||
|
||||
// newTLSReport constructs a new tlsReport configured with the given app version and known pinned public keys.
|
||||
// Temporal things (current date/time) are not set yet -- they are set when sendReport is called.
|
||||
func newTLSReport(host, port, server string, certChain, knownPins []string, appVersion string) (report tlsReport) {
|
||||
// If we can't parse the port for whatever reason, it doesn't really matter; we should report anyway.
|
||||
intPort, _ := strconv.Atoi(port)
|
||||
|
||||
report = tlsReport{
|
||||
Hostname: host,
|
||||
Port: intPort,
|
||||
NotedHostname: server,
|
||||
ServedCertificateChain: certChain,
|
||||
KnownPins: knownPins,
|
||||
AppVersion: appVersion,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// sendReport posts the given TLS report to the standard TLS Report URI.
|
||||
func (r tlsReport) sendReport(uri, userAgent string) {
|
||||
now := time.Now()
|
||||
r.DateTime = now.Format(time.RFC3339)
|
||||
r.EffectiveExpirationDate = now.Add(365 * 24 * 60 * 60 * time.Second).Format(time.RFC3339)
|
||||
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to marshal TLS report")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", uri, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to create http request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("x-pm-appversion", r.AppVersion)
|
||||
|
||||
logrus.WithField("request", req).Warn("Reporting TLS mismatch")
|
||||
res, err := (&http.Client{Transport: CreateTransportWithDialer(NewBasicTLSDialer())}).Do(req)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to report TLS mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithField("response", res).Error("Reported TLS mismatch")
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
logrus.WithField("status", http.StatusOK).Error("StatusCode was not OK")
|
||||
}
|
||||
|
||||
_, _ = ioutil.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
8
pkg/pmapi/types.go
Normal file
8
pkg/pmapi/types.go
Normal file
@ -0,0 +1,8 @@
|
||||
package pmapi
|
||||
|
||||
type Boolean int
|
||||
|
||||
const (
|
||||
False Boolean = iota
|
||||
True
|
||||
)
|
||||
@ -18,7 +18,10 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@ -81,11 +84,18 @@ type User struct {
|
||||
}
|
||||
}
|
||||
|
||||
// UserRes holds structure of JSON response.
|
||||
type UserRes struct {
|
||||
Res
|
||||
func (c *client) getUser(ctx context.Context) (user *User, err error) {
|
||||
var res struct {
|
||||
User *User
|
||||
}
|
||||
|
||||
User *User
|
||||
if _, err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||
return r.SetResult(&res).Get("/users")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.User, nil
|
||||
}
|
||||
|
||||
// unlockUser unlocks all the client's user keys using the given passphrase.
|
||||
@ -102,40 +112,29 @@ func (c *client) unlockUser(passphrase []byte) (err error) {
|
||||
}
|
||||
|
||||
// UpdateUser retrieves details about user and loads its addresses.
|
||||
func (c *client) UpdateUser() (user *User, err error) {
|
||||
req, err := c.NewRequest("GET", "/users", nil)
|
||||
func (c *client) UpdateUser(ctx context.Context) (*User, error) {
|
||||
user, err := c.getUser(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res UserRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, err = res.User, res.Err()
|
||||
addresses, err := c.GetAddresses(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.user = user
|
||||
sentry.ConfigureScope(func(scope *sentry.Scope) {
|
||||
scope.SetUser(sentry.User{ID: user.ID})
|
||||
})
|
||||
|
||||
var tmpList AddressList
|
||||
if tmpList, err = c.GetAddresses(); err == nil {
|
||||
c.addresses = tmpList
|
||||
}
|
||||
c.addresses = addresses
|
||||
sentry.ConfigureScope(func(scope *sentry.Scope) { scope.SetUser(sentry.User{ID: user.ID}) })
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// CurrentUser returns currently active user or user will be updated.
|
||||
func (c *client) CurrentUser() (user *User, err error) {
|
||||
func (c *client) CurrentUser(ctx context.Context) (*User, error) {
|
||||
if c.user != nil && len(c.addresses) != 0 {
|
||||
user = c.user
|
||||
return
|
||||
return c.user, nil
|
||||
}
|
||||
return c.UpdateUser()
|
||||
|
||||
return c.UpdateUser(ctx)
|
||||
}
|
||||
|
||||
@ -18,9 +18,8 @@
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
@ -60,38 +59,17 @@ const testPublicKeysBody = `{
|
||||
]}`
|
||||
|
||||
func TestClient_CurrentUser(t *testing.T) {
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
routeGetUsers,
|
||||
routeGetAddresses,
|
||||
)
|
||||
defer finish()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
user, err := c.CurrentUser()
|
||||
user, err := c.CurrentUser(context.TODO())
|
||||
r.Nil(t, err)
|
||||
|
||||
// Ignore KeyRings during the check because they have unexported fields and cannot be compared
|
||||
r.True(t, cmp.Equal(user, testCurrentUser, cmpopts.IgnoreTypes(&crypto.Key{})))
|
||||
|
||||
r.Nil(t, c.Unlock([]byte(testMailboxPassword)))
|
||||
}
|
||||
|
||||
func TestClient_PublicKeys(t *testing.T) {
|
||||
email := "jason@protonmail.com"
|
||||
escaped := url.QueryEscape(email)
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/keys?Email="+escaped))
|
||||
fmt.Fprint(w, testPublicKeysBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
keys, err := c.PublicKeys([]string{email})
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting current user, got:", err)
|
||||
}
|
||||
|
||||
if len(keys) != 1 || keys[escaped] == nil {
|
||||
t.Fatalf("Expected only one key for %v, got %#v", email, keys)
|
||||
}
|
||||
r.Nil(t, c.Unlock(context.TODO(), []byte(testMailboxPassword)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user