mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-11 05:06:51 +00:00
We build too many walls and not enough bridges
This commit is contained in:
19
pkg/algo/algo.go
Normal file
19
pkg/algo/algo.go
Normal file
@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2020 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 algo provides some algorithm utils.
|
||||
package algo
|
||||
47
pkg/algo/sets.go
Normal file
47
pkg/algo/sets.go
Normal file
@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2020 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 algo
|
||||
|
||||
import "reflect"
|
||||
|
||||
// SetIntersection complexity: O(n^2), could be better but this is simple enough
|
||||
func SetIntersection(a, b interface{}, eq func(a, b interface{}) bool) []interface{} {
|
||||
set := make([]interface{}, 0)
|
||||
av := reflect.ValueOf(a)
|
||||
|
||||
for i := 0; i < av.Len(); i++ {
|
||||
el := av.Index(i).Interface()
|
||||
if contains(b, el, eq) {
|
||||
set = append(set, el)
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
func contains(a, e interface{}, eq func(a, b interface{}) bool) bool {
|
||||
v := reflect.ValueOf(a)
|
||||
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if eq(v.Index(i).Interface(), e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
71
pkg/algo/sets_test.go
Normal file
71
pkg/algo/sets_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2020 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 algo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
type T struct {
|
||||
k, v int
|
||||
}
|
||||
|
||||
func TestSetIntersection(t *testing.T) {
|
||||
keysAreEqual := func(a, b interface{}) bool {
|
||||
return a.(T).k == b.(T).k
|
||||
}
|
||||
|
||||
type args struct {
|
||||
a interface{}
|
||||
b interface{}
|
||||
eq func(a, b interface{}) bool
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want interface{}
|
||||
}{
|
||||
{
|
||||
name: "integer sets",
|
||||
args: args{a: []int{1, 2, 3}, b: []int{3, 4, 5}, eq: func(a, b interface{}) bool { return a == b }},
|
||||
want: []int{3},
|
||||
},
|
||||
{
|
||||
name: "string sets",
|
||||
args: args{a: []string{"1", "2", "3"}, b: []string{"3", "4", "5"}, eq: func(a, b interface{}) bool { return a == b }},
|
||||
want: []string{"3"},
|
||||
},
|
||||
{
|
||||
name: "custom comp, only compare on keys, prefer first set if keys are the same",
|
||||
args: args{a: []T{{k: 1, v: 1}, {k: 2, v: 2}}, b: []T{{k: 2, v: 1234}, {k: 3, v: 3}}, eq: keysAreEqual},
|
||||
want: []T{{k: 2, v: 2}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// using cmp.Equal because it handles the interfaces correctly; testify/assert doesn't
|
||||
// treat these as equal because their types are different ([]interface vs []int)
|
||||
if got := SetIntersection(tt.args.a, tt.args.b, tt.args.eq); cmp.Equal(got, tt.want) {
|
||||
t.Errorf("SetIntersection() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
35
pkg/args/args.go
Normal file
35
pkg/args/args.go
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2020 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 args
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilterProcessSerialNumberFromArgs removes additional flag from MacOS. More info ProcessSerialNumber
|
||||
// http://mirror.informatimago.com/next/developer.apple.com/documentation/Carbon/Reference/Process_Manager/prmref_main/data_type_5.html#//apple_ref/doc/uid/TP30000208/C001951
|
||||
func FilterProcessSerialNumberFromArgs() {
|
||||
tmp := os.Args[:0]
|
||||
for _, arg := range os.Args {
|
||||
if !strings.Contains(arg, "-psn_") {
|
||||
tmp = append(tmp, arg)
|
||||
}
|
||||
}
|
||||
os.Args = tmp
|
||||
}
|
||||
259
pkg/config/config.go
Normal file
259
pkg/config/config.go
Normal file
@ -0,0 +1,259 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-appdir"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
var (
|
||||
log = GetLogEntry("config") //nolint[gochecknoglobals]
|
||||
)
|
||||
|
||||
type appDirProvider interface {
|
||||
UserConfig() string
|
||||
UserCache() string
|
||||
UserLogs() string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
appName string
|
||||
version string
|
||||
revision string
|
||||
cacheVersion string
|
||||
appDirs appDirProvider
|
||||
appDirsVersion appDirProvider
|
||||
apiConfig *pmapi.ClientConfig
|
||||
}
|
||||
|
||||
// New returns fully initialized config struct.
|
||||
// `appName` should be in camelCase format for folder or file names. It's also used in API
|
||||
// as `AppVersion` which is converted to CamelCase.
|
||||
// `version` is the version of the app (e.g. v1.2.3).
|
||||
// `cacheVersion` is the version of the cache files (setting a different number will remove the old ones).
|
||||
func New(appName, version, revision, cacheVersion string) *Config {
|
||||
appDirs := appdir.New(filepath.Join("protonmail", appName))
|
||||
appDirsVersion := appdir.New(filepath.Join("protonmail", appName, cacheVersion))
|
||||
return newConfig(appName, version, revision, cacheVersion, appDirs, appDirsVersion)
|
||||
}
|
||||
|
||||
func newConfig(appName, version, revision, cacheVersion string, appDirs, appDirsVersion appDirProvider) *Config {
|
||||
return &Config{
|
||||
appName: appName,
|
||||
version: version,
|
||||
revision: revision,
|
||||
cacheVersion: cacheVersion,
|
||||
appDirs: appDirs,
|
||||
appDirsVersion: appDirsVersion,
|
||||
apiConfig: &pmapi.ClientConfig{
|
||||
AppVersion: strings.Title(appName) + "_" + version,
|
||||
ClientID: appName,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
},
|
||||
// TokenManager should not be required, but PMAPI still doesn't handle not-set cases everywhere.
|
||||
TokenManager: pmapi.NewTokenManager(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDirs creates all folders that are necessary for bridge to properly function.
|
||||
func (c *Config) CreateDirs() error {
|
||||
// Log files.
|
||||
if err := os.MkdirAll(c.appDirs.UserLogs(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
// TLS files.
|
||||
if err := os.MkdirAll(c.appDirs.UserConfig(), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
// Lock, events, preferences, user_info, db files.
|
||||
if err := os.MkdirAll(c.appDirsVersion.UserCache(), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearData removes all files except the lock file.
|
||||
// The lock file will be removed when the Bridge stops.
|
||||
func (c *Config) ClearData() error {
|
||||
dirs := []string{
|
||||
c.appDirs.UserLogs(),
|
||||
c.appDirs.UserConfig(),
|
||||
c.appDirs.UserCache(),
|
||||
}
|
||||
shouldRemove := func(filePath string) bool {
|
||||
return filePath != c.GetLockPath()
|
||||
}
|
||||
return c.removeAllExcept(dirs, shouldRemove)
|
||||
}
|
||||
|
||||
// ClearOldData removes all old files, such as old log files or old versions of cache and so on.
|
||||
func (c *Config) ClearOldData() error {
|
||||
// `appDirs` is parent for `appDirsVersion`.
|
||||
// `dir` then contains all subfolders and only `cacheVersion` should stay.
|
||||
// But on Windows all files (dirs) are in the same one - we cannot remove log, lock or tls files.
|
||||
dir := c.appDirs.UserCache()
|
||||
|
||||
return c.removeExcept(dir, func(filePath string) bool {
|
||||
fileName := filepath.Base(filePath)
|
||||
return (fileName != c.cacheVersion &&
|
||||
!logFileRgx.MatchString(fileName) &&
|
||||
filePath != c.GetTLSCertPath() &&
|
||||
filePath != c.GetTLSKeyPath() &&
|
||||
filePath != c.GetEventsPath() &&
|
||||
filePath != c.GetIMAPCachePath() &&
|
||||
filePath != c.GetLockPath() &&
|
||||
filePath != c.GetPreferencesPath())
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Config) removeAllExcept(dirs []string, shouldRemove func(string) bool) error {
|
||||
var result *multierror.Error
|
||||
for _, dir := range dirs {
|
||||
if err := c.removeExcept(dir, shouldRemove); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
func (c *Config) removeExcept(dir string, shouldRemove func(string) bool) error {
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result *multierror.Error
|
||||
for _, file := range files {
|
||||
filePath := filepath.Join(dir, file.Name())
|
||||
if !shouldRemove(filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !file.IsDir() {
|
||||
if err := os.RemoveAll(filePath); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
subDir := filepath.Join(dir, file.Name())
|
||||
if err := c.removeExcept(subDir, shouldRemove); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
} else {
|
||||
// Remove dir itself only if it's empty.
|
||||
subFiles, err := ioutil.ReadDir(subDir)
|
||||
if err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
} else if len(subFiles) == 0 {
|
||||
if err := os.RemoveAll(subDir); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
// IsDevMode should be used for development conditions such us whether to send sentry reports.
|
||||
func (c *Config) IsDevMode() bool {
|
||||
return os.Getenv("PROTONMAIL_ENV") == "dev"
|
||||
}
|
||||
|
||||
// GetLogDir returns folder for log files.
|
||||
func (c *Config) GetLogDir() string {
|
||||
return c.appDirs.UserLogs()
|
||||
}
|
||||
|
||||
// GetLogPrefix returns prefix for log files. Bridge uses format vVERSION.
|
||||
func (c *Config) GetLogPrefix() string {
|
||||
return "v" + c.version + "_" + c.revision
|
||||
}
|
||||
|
||||
// GetTLSCertPath returns path to certificate; used for TLS servers (IMAP, SMTP and API).
|
||||
func (c *Config) GetTLSCertPath() string {
|
||||
return filepath.Join(c.appDirs.UserConfig(), "cert.pem")
|
||||
}
|
||||
|
||||
// GetTLSKeyPath returns path to private key; used for TLS servers (IMAP, SMTP and API).
|
||||
func (c *Config) GetTLSKeyPath() string {
|
||||
return filepath.Join(c.appDirs.UserConfig(), "key.pem")
|
||||
}
|
||||
|
||||
// GetDBDir returns folder for db files.
|
||||
func (c *Config) GetDBDir() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache())
|
||||
}
|
||||
|
||||
// GetEventsPath returns path to events file containing the last processed event IDs.
|
||||
func (c *Config) GetEventsPath() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache(), "events.json")
|
||||
}
|
||||
|
||||
// GetIMAPCachePath returns path to file with IMAP status.
|
||||
func (c *Config) GetIMAPCachePath() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache(), "user_info.json")
|
||||
}
|
||||
|
||||
// GetLockPath returns path to lock file to check if bridge is already running.
|
||||
func (c *Config) GetLockPath() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache(), c.appName+".lock")
|
||||
}
|
||||
|
||||
// GetUpdateDir returns folder for update files; such as new binary.
|
||||
func (c *Config) GetUpdateDir() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache(), "updates")
|
||||
}
|
||||
|
||||
// GetPreferencesPath returns path to preference file.
|
||||
func (c *Config) GetPreferencesPath() string {
|
||||
return filepath.Join(c.appDirsVersion.UserCache(), "prefs.json")
|
||||
}
|
||||
|
||||
// GetAPIConfig returns config for ProtonMail API.
|
||||
func (c *Config) GetAPIConfig() *pmapi.ClientConfig {
|
||||
return c.apiConfig
|
||||
}
|
||||
|
||||
// GetDefaultAPIPort returns default Bridge local API port.
|
||||
func (c *Config) GetDefaultAPIPort() int {
|
||||
return 1042
|
||||
}
|
||||
|
||||
// GetDefaultIMAPPort returns default Bridge IMAP port.
|
||||
func (c *Config) GetDefaultIMAPPort() int {
|
||||
return 1143
|
||||
}
|
||||
|
||||
// GetDefaultSMTPPort returns default Bridge SMTP port.
|
||||
func (c *Config) GetDefaultSMTPPort() int {
|
||||
return 1025
|
||||
}
|
||||
238
pkg/config/config_test.go
Normal file
238
pkg/config/config_test.go
Normal file
@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testAppName = "bridge-test"
|
||||
|
||||
var testConfigDir string //nolint[gochecknoglobals]
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
setupTestConfig()
|
||||
setupTestLogs()
|
||||
code := m.Run()
|
||||
shutdownTestConfig()
|
||||
shutdownTestLogs()
|
||||
shutdownTestPreferences()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func setupTestConfig() {
|
||||
var err error
|
||||
testConfigDir, err = ioutil.TempDir("", "config")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownTestConfig() {
|
||||
_ = os.RemoveAll(testConfigDir)
|
||||
}
|
||||
|
||||
type mocks struct {
|
||||
t *testing.T
|
||||
|
||||
ctrl *gomock.Controller
|
||||
appDir *MockappDirer
|
||||
appDirVersion *MockappDirer
|
||||
}
|
||||
|
||||
func initMocks(t *testing.T) mocks {
|
||||
mockCtrl := gomock.NewController(t)
|
||||
return mocks{
|
||||
t: t,
|
||||
|
||||
ctrl: mockCtrl,
|
||||
appDir: NewMockappDirer(mockCtrl),
|
||||
appDirVersion: NewMockappDirer(mockCtrl),
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearDataLinux(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
createTestStructureLinux(m, testConfigDir)
|
||||
cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion)
|
||||
require.NoError(t, cfg.ClearData())
|
||||
checkFileNames(t, testConfigDir, []string{
|
||||
"cache",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"config",
|
||||
"logs",
|
||||
})
|
||||
}
|
||||
|
||||
func TestClearDataWindows(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
createTestStructureWindows(m, testConfigDir)
|
||||
cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion)
|
||||
require.NoError(t, cfg.ClearData())
|
||||
checkFileNames(t, testConfigDir, []string{
|
||||
"cache",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"config",
|
||||
})
|
||||
}
|
||||
|
||||
// OldData touches only cache folder.
|
||||
// Removes only c1 folder as nothing else is part of cache folder on Linux/Mac.
|
||||
func TestClearOldDataLinux(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
createTestStructureLinux(m, testConfigDir)
|
||||
cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion)
|
||||
require.NoError(t, cfg.ClearOldData())
|
||||
checkFileNames(t, testConfigDir, []string{
|
||||
"cache",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"cache/c2/events.json",
|
||||
"cache/c2/mailbox-user@pm.me.db",
|
||||
"cache/c2/prefs.json",
|
||||
"cache/c2/updates",
|
||||
"cache/c2/user_info.json",
|
||||
"config",
|
||||
"config/cert.pem",
|
||||
"config/key.pem",
|
||||
"logs",
|
||||
"logs/other.log",
|
||||
"logs/v1_10.log",
|
||||
"logs/v1_11.log",
|
||||
"logs/v2_12.log",
|
||||
"logs/v2_13.log",
|
||||
})
|
||||
}
|
||||
|
||||
// OldData touches only cache folder. Removes everything except c2 folder
|
||||
// and bridge log files which are part of cache folder on Windows.
|
||||
func TestClearOldDataWindows(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
createTestStructureWindows(m, testConfigDir)
|
||||
cfg := newConfig(testAppName, "v1", "rev123", "c2", m.appDir, m.appDirVersion)
|
||||
require.NoError(t, cfg.ClearOldData())
|
||||
checkFileNames(t, testConfigDir, []string{
|
||||
"cache",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"cache/c2/events.json",
|
||||
"cache/c2/mailbox-user@pm.me.db",
|
||||
"cache/c2/prefs.json",
|
||||
"cache/c2/updates",
|
||||
"cache/c2/user_info.json",
|
||||
"cache/v1_10.log",
|
||||
"cache/v1_11.log",
|
||||
"cache/v2_12.log",
|
||||
"cache/v2_13.log",
|
||||
"config",
|
||||
"config/cert.pem",
|
||||
"config/key.pem",
|
||||
})
|
||||
}
|
||||
|
||||
func createTestStructureLinux(m mocks, baseDir string) {
|
||||
logsDir := filepath.Join(baseDir, "logs")
|
||||
configDir := filepath.Join(baseDir, "config")
|
||||
cacheDir := filepath.Join(baseDir, "cache")
|
||||
versionedOldCacheDir := filepath.Join(baseDir, "cache", "c1")
|
||||
versionedCacheDir := filepath.Join(baseDir, "cache", "c2")
|
||||
createTestStructure(m, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir)
|
||||
}
|
||||
|
||||
func createTestStructureWindows(m mocks, baseDir string) {
|
||||
logsDir := filepath.Join(baseDir, "cache")
|
||||
configDir := filepath.Join(baseDir, "config")
|
||||
cacheDir := filepath.Join(baseDir, "cache")
|
||||
versionedOldCacheDir := filepath.Join(baseDir, "cache", "c1")
|
||||
versionedCacheDir := filepath.Join(baseDir, "cache", "c2")
|
||||
createTestStructure(m, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir)
|
||||
}
|
||||
|
||||
func createTestStructure(m mocks, baseDir, logsDir, configDir, cacheDir, versionedOldCacheDir, versionedCacheDir string) {
|
||||
m.appDir.EXPECT().UserLogs().Return(logsDir).AnyTimes()
|
||||
m.appDir.EXPECT().UserConfig().Return(configDir).AnyTimes()
|
||||
m.appDir.EXPECT().UserCache().Return(cacheDir).AnyTimes()
|
||||
m.appDirVersion.EXPECT().UserCache().Return(versionedCacheDir).AnyTimes()
|
||||
|
||||
require.NoError(m.t, os.RemoveAll(baseDir))
|
||||
require.NoError(m.t, os.MkdirAll(baseDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(logsDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(configDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(cacheDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(versionedOldCacheDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(versionedCacheDir, 0700))
|
||||
require.NoError(m.t, os.MkdirAll(filepath.Join(versionedCacheDir, "updates"), 0700))
|
||||
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "other.log"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v1_10.log"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v1_11.log"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v2_12.log"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(logsDir, "v2_13.log"), []byte("Hello"), 0755))
|
||||
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(configDir, "cert.pem"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(configDir, "key.pem"), []byte("Hello"), 0755))
|
||||
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "prefs.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "events.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "user_info.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedOldCacheDir, "mailbox-user@pm.me.db"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "prefs.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "events.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "user_info.json"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, testAppName+".lock"), []byte("Hello"), 0755))
|
||||
require.NoError(m.t, ioutil.WriteFile(filepath.Join(versionedCacheDir, "mailbox-user@pm.me.db"), []byte("Hello"), 0755))
|
||||
}
|
||||
|
||||
func checkFileNames(t *testing.T, dir string, expectedFileNames []string) {
|
||||
fileNames := getFileNames(t, dir)
|
||||
require.Equal(t, expectedFileNames, fileNames)
|
||||
}
|
||||
|
||||
func getFileNames(t *testing.T, dir string) []string {
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
fileNames := []string{}
|
||||
for _, file := range files {
|
||||
fileNames = append(fileNames, file.Name())
|
||||
if file.IsDir() {
|
||||
subDir := filepath.Join(dir, file.Name())
|
||||
subFileNames := getFileNames(t, subDir)
|
||||
for _, subFileName := range subFileNames {
|
||||
fileNames = append(fileNames, file.Name()+"/"+subFileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileNames
|
||||
}
|
||||
252
pkg/config/logs.go
Normal file
252
pkg/config/logs.go
Normal file
@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type logConfiger interface {
|
||||
GetLogDir() string
|
||||
GetLogPrefix() string
|
||||
}
|
||||
|
||||
const (
|
||||
// Zendesk now has a file size limit of 20MB. When the last N log files
|
||||
// are zipped, it should fit under 20MB. Value in MB (average file has
|
||||
// few hundreds kB).
|
||||
maxLogFileSize = 10 * 1024 * 1024 //nolint[gochecknoglobals]
|
||||
// Including the current logfile.
|
||||
maxNumberLogFiles = 3 //nolint[gochecknoglobals]
|
||||
)
|
||||
|
||||
// logFile is pointer to currently open file used by logrus.
|
||||
var logFile *os.File //nolint[gochecknoglobals]
|
||||
|
||||
var logFileRgx = regexp.MustCompile("^v.*\\.log$") //nolint[gochecknoglobals]
|
||||
var logCrashRgx = regexp.MustCompile("^v.*_crash_.*\\.log$") //nolint[gochecknoglobals]
|
||||
|
||||
// GetLogEntry returns logrus.Entry with PID and `packageName`.
|
||||
func GetLogEntry(packageName string) *logrus.Entry {
|
||||
return logrus.WithFields(logrus.Fields{
|
||||
"pkg": packageName,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlePanic reports the crash to sentry or local file when sentry fails.
|
||||
func HandlePanic(cfg *Config, output string) {
|
||||
if !cfg.IsDevMode() {
|
||||
c := pmapi.NewClient(cfg.GetAPIConfig(), "no-user-id")
|
||||
err := c.ReportSentryCrash(fmt.Errorf(output))
|
||||
if err != nil {
|
||||
log.Error("Sentry crash report failed: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
filename := getLogFilename(cfg.GetLogPrefix() + "_crash_")
|
||||
filepath := filepath.Join(cfg.GetLogDir(), filename)
|
||||
f, err := os.OpenFile(filepath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
log.Error("Cannot open file to write crash report: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = f.WriteString(output)
|
||||
_ = pprof.Lookup("goroutine").WriteTo(f, 2)
|
||||
|
||||
log.Warn("Crash report saved to ", filepath)
|
||||
}
|
||||
|
||||
// GetGID returns goroutine number which can be used to distiguish logs from
|
||||
// the concurent processes. Keep in mind that it returns the number of routine
|
||||
// which executes the function.
|
||||
func GetGID() uint64 {
|
||||
b := make([]byte, 64)
|
||||
b = b[:runtime.Stack(b, false)]
|
||||
b = bytes.TrimPrefix(b, []byte("goroutine "))
|
||||
b = b[:bytes.IndexByte(b, ' ')]
|
||||
n, _ := strconv.ParseUint(string(b), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// SetupLog set up log level, formatter and output (file or stdout).
|
||||
// Returns whether should be used debug for IMAP and SMTP servers.
|
||||
func SetupLog(cfg logConfiger, levelFlag string) (debugClient, debugServer bool) {
|
||||
level, useFile := getLogLevelAndFile(levelFlag)
|
||||
|
||||
logrus.SetLevel(level)
|
||||
|
||||
if useFile {
|
||||
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||
setLogFile(cfg.GetLogDir(), cfg.GetLogPrefix())
|
||||
watchLogFileSize(cfg.GetLogDir(), cfg.GetLogPrefix())
|
||||
} else {
|
||||
logrus.SetFormatter(&logrus.TextFormatter{
|
||||
ForceColors: true,
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: time.StampMilli,
|
||||
})
|
||||
logrus.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
switch levelFlag {
|
||||
case "debug-client", "debug-client-json":
|
||||
debugClient = true
|
||||
case "debug-server", "debug-server-json", "trace":
|
||||
fmt.Println("THE LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
|
||||
log.Warning("================================================")
|
||||
log.Warning("THIS LOG WILL CONTAIN **DECRYPTED** MESSAGE DATA")
|
||||
log.Warning("================================================")
|
||||
debugClient = true
|
||||
debugServer = true
|
||||
}
|
||||
|
||||
return debugClient, debugServer
|
||||
}
|
||||
|
||||
func setLogFile(logDir, logPrefix string) {
|
||||
if logFile != nil {
|
||||
return
|
||||
}
|
||||
|
||||
filename := getLogFilename(logPrefix)
|
||||
var err error
|
||||
logFile, err = os.OpenFile(filepath.Join(logDir, filename), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
logrus.SetOutput(logFile)
|
||||
|
||||
// Users sometimes change the name of the log file. We want to always log
|
||||
// information about bridge version (included in log prefix) and OS.
|
||||
log.Warn("Bridge version: ", logPrefix, " ", runtime.GOOS)
|
||||
}
|
||||
|
||||
func getLogFilename(logPrefix string) string {
|
||||
currentTime := strconv.Itoa(int(time.Now().Unix()))
|
||||
return logPrefix + "_" + currentTime + ".log"
|
||||
}
|
||||
|
||||
func watchLogFileSize(logDir, logPrefix string) {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
checkLogFileSize(logDir, logPrefix)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func checkLogFileSize(logDir, logPrefix string) {
|
||||
if logFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stat, err := logFile.Stat()
|
||||
if err != nil {
|
||||
log.Error("Log file size check failed: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
if stat.Size() >= maxLogFileSize {
|
||||
log.Warn("Current log file ", logFile.Name(), " is too big, opening new file")
|
||||
closeLogFile()
|
||||
setLogFile(logDir, logPrefix)
|
||||
}
|
||||
|
||||
if err := clearLogs(logDir); err != nil {
|
||||
log.Error("Cannot clear logs ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeLogFile() {
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
logFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogs(logDir string) error {
|
||||
files, err := ioutil.ReadDir(logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var logsWithPrefix []string
|
||||
var crashesWithPrefix []string
|
||||
|
||||
for _, file := range files {
|
||||
if logFileRgx.MatchString(file.Name()) {
|
||||
if logCrashRgx.MatchString(file.Name()) {
|
||||
crashesWithPrefix = append(crashesWithPrefix, file.Name())
|
||||
} else {
|
||||
logsWithPrefix = append(logsWithPrefix, file.Name())
|
||||
}
|
||||
} else {
|
||||
// Older versions of Bridge stored logs in subfolders for each version.
|
||||
// That also has to be cleared and the functionality can be removed after some time.
|
||||
if file.IsDir() {
|
||||
if err := clearLogs(filepath.Join(logDir, file.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
removeLog(logDir, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeOldLogs(logDir, logsWithPrefix)
|
||||
removeOldLogs(logDir, crashesWithPrefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeOldLogs(logDir string, filenames []string) {
|
||||
count := len(filenames)
|
||||
if count <= maxNumberLogFiles {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Strings(filenames) // Sorted by timestamp: oldest first.
|
||||
for _, filename := range filenames[:count-maxNumberLogFiles] {
|
||||
removeLog(logDir, filename)
|
||||
}
|
||||
}
|
||||
|
||||
func removeLog(logDir, filename string) {
|
||||
// We need to be sure to delete only log files.
|
||||
// Directory with logs can also contain other files.
|
||||
if !logFileRgx.MatchString(filename) {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(logDir, filename)); err != nil {
|
||||
log.Error("Cannot remove old logs ", err)
|
||||
}
|
||||
}
|
||||
49
pkg/config/logs_all.go
Normal file
49
pkg/config/logs_all.go
Normal file
@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func getLogLevelAndFile(levelFlag string) (level logrus.Level, useFile bool) {
|
||||
useFile = true
|
||||
switch levelFlag {
|
||||
case "panic":
|
||||
level = logrus.PanicLevel
|
||||
case "fatal":
|
||||
level = logrus.FatalLevel
|
||||
case "error":
|
||||
level = logrus.ErrorLevel
|
||||
case "warn":
|
||||
level = logrus.WarnLevel
|
||||
case "info":
|
||||
level = logrus.InfoLevel
|
||||
case "debug", "debug-client", "debug-server", "debug-client-json", "debug-server-json":
|
||||
level = logrus.DebugLevel
|
||||
useFile = false
|
||||
case "trace":
|
||||
level = logrus.TraceLevel
|
||||
useFile = false
|
||||
default:
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
return
|
||||
}
|
||||
50
pkg/config/logs_qa.go
Normal file
50
pkg/config/logs_qa.go
Normal file
@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// getLogLevelAndFile for QA build is altered in a way even decrypted data are stored
|
||||
// in the log file when forced with `debug-client-json` or `debug-server-json`.
|
||||
func getLogLevelAndFile(levelFlag string) (level logrus.Level, useFile bool) {
|
||||
useFile = true
|
||||
switch levelFlag {
|
||||
case "panic":
|
||||
level = logrus.PanicLevel
|
||||
case "fatal":
|
||||
level = logrus.FatalLevel
|
||||
case "error":
|
||||
level = logrus.ErrorLevel
|
||||
case "warn":
|
||||
level = logrus.WarnLevel
|
||||
case "info":
|
||||
level = logrus.InfoLevel
|
||||
case "debug-client-json", "debug-server-json":
|
||||
level = logrus.DebugLevel
|
||||
case "debug", "debug-client", "debug-server":
|
||||
level = logrus.DebugLevel
|
||||
useFile = false
|
||||
default:
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
return
|
||||
}
|
||||
225
pkg/config/logs_test.go
Normal file
225
pkg/config/logs_test.go
Normal file
@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testLogConfig struct{ logDir, logPrefix string }
|
||||
|
||||
func (c *testLogConfig) GetLogDir() string { return c.logDir }
|
||||
func (c *testLogConfig) GetLogPrefix() string { return c.logPrefix }
|
||||
|
||||
var testLogDir string //nolint[gochecknoglobals]
|
||||
|
||||
func setupTestLogs() {
|
||||
var err error
|
||||
testLogDir, err = ioutil.TempDir("", "log")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownTestLogs() {
|
||||
_ = os.RemoveAll(testLogDir)
|
||||
}
|
||||
|
||||
func TestLogNameLength(t *testing.T) {
|
||||
cfg := New("bridge-test", "longVersion123", "longRevision1234567890", "c2")
|
||||
name := getLogFilename(cfg.GetLogPrefix())
|
||||
if len(name) > 128 {
|
||||
t.Fatal("Name of the log is too long - limit for encrypted linux is 128 characters")
|
||||
}
|
||||
}
|
||||
|
||||
// Info and higher levels writes to the file.
|
||||
func TestSetupLogInfo(t *testing.T) {
|
||||
dir := beforeEachCreateTestDir(t, "setupInfo")
|
||||
|
||||
SetupLog(&testLogConfig{dir, "v"}, "info")
|
||||
require.Equal(t, "info", logrus.GetLevel().String())
|
||||
|
||||
logrus.Info("test message")
|
||||
files := checkLogFiles(t, dir, 1)
|
||||
checkLogContains(t, dir, files[0].Name(), "test message")
|
||||
}
|
||||
|
||||
// Debug levels writes to stdout.
|
||||
func TestSetupLogDebug(t *testing.T) {
|
||||
dir := beforeEachCreateTestDir(t, "setupDebug")
|
||||
|
||||
SetupLog(&testLogConfig{dir, "v"}, "debug")
|
||||
require.Equal(t, "debug", logrus.GetLevel().String())
|
||||
|
||||
logrus.Info("test message")
|
||||
checkLogFiles(t, dir, 0)
|
||||
}
|
||||
|
||||
func TestReopenLogFile(t *testing.T) {
|
||||
dir := beforeEachCreateTestDir(t, "reopenLogFile")
|
||||
|
||||
setLogFile(dir, "v1")
|
||||
|
||||
done := make(chan interface{})
|
||||
|
||||
log.Info("first message")
|
||||
|
||||
go func() {
|
||||
<-done // Wait for closing file and opening new one.
|
||||
log.Info("second message")
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
closeLogFile()
|
||||
setLogFile(dir, "v2")
|
||||
|
||||
done <- nil
|
||||
<-done // Wait for second log message.
|
||||
|
||||
files := checkLogFiles(t, dir, 2)
|
||||
checkLogContains(t, dir, files[0].Name(), "first message")
|
||||
checkLogContains(t, dir, files[1].Name(), "second message")
|
||||
}
|
||||
|
||||
func TestCheckLogFileSizeSmall(t *testing.T) {
|
||||
dir := beforeEachCreateTestDir(t, "logFileSizeSmall")
|
||||
|
||||
setLogFile(dir, "v1")
|
||||
originalFileName := logFile.Name()
|
||||
|
||||
_, _ = logFile.WriteString("small file")
|
||||
checkLogFileSize(dir, "v2")
|
||||
|
||||
require.Equal(t, originalFileName, logFile.Name())
|
||||
}
|
||||
|
||||
func TestCheckLogFileSizeBig(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
dir := beforeEachCreateTestDir(t, "logFileSizeBig")
|
||||
|
||||
setLogFile(dir, "v1")
|
||||
originalFileName := logFile.Name()
|
||||
|
||||
// The limit for big file is 10*1024*1024 - keep the string 10 letters long.
|
||||
for i := 0; i < 1024*1024; i++ {
|
||||
_, _ = logFile.WriteString("big file!\n")
|
||||
}
|
||||
checkLogFileSize(dir, "v2")
|
||||
|
||||
require.NotEqual(t, originalFileName, logFile.Name())
|
||||
}
|
||||
|
||||
// ClearLogs removes only bridge old log files keeping last three of them.
|
||||
func TestClearLogsLinux(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
dir := beforeEachCreateTestDir(t, "clearLogs")
|
||||
|
||||
createTestStructureLinux(m, dir)
|
||||
require.NoError(t, clearLogs(dir))
|
||||
checkFileNames(t, dir, []string{
|
||||
"cache",
|
||||
"cache/c1",
|
||||
"cache/c1/events.json",
|
||||
"cache/c1/mailbox-user@pm.me.db",
|
||||
"cache/c1/prefs.json",
|
||||
"cache/c1/user_info.json",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"cache/c2/events.json",
|
||||
"cache/c2/mailbox-user@pm.me.db",
|
||||
"cache/c2/prefs.json",
|
||||
"cache/c2/updates",
|
||||
"cache/c2/user_info.json",
|
||||
"config",
|
||||
"config/cert.pem",
|
||||
"config/key.pem",
|
||||
"logs",
|
||||
"logs/other.log",
|
||||
"logs/v1_11.log",
|
||||
"logs/v2_12.log",
|
||||
"logs/v2_13.log",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearLogs removes only bridge old log files even when log folder
|
||||
// is shared with other files on Windows.
|
||||
func TestClearLogsWindows(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
dir := beforeEachCreateTestDir(t, "clearLogs")
|
||||
|
||||
createTestStructureWindows(m, dir)
|
||||
require.NoError(t, clearLogs(dir))
|
||||
checkFileNames(t, dir, []string{
|
||||
"cache",
|
||||
"cache/c1",
|
||||
"cache/c1/events.json",
|
||||
"cache/c1/mailbox-user@pm.me.db",
|
||||
"cache/c1/prefs.json",
|
||||
"cache/c1/user_info.json",
|
||||
"cache/c2",
|
||||
"cache/c2/bridge-test.lock",
|
||||
"cache/c2/events.json",
|
||||
"cache/c2/mailbox-user@pm.me.db",
|
||||
"cache/c2/prefs.json",
|
||||
"cache/c2/updates",
|
||||
"cache/c2/user_info.json",
|
||||
"cache/other.log",
|
||||
"cache/v1_11.log",
|
||||
"cache/v2_12.log",
|
||||
"cache/v2_13.log",
|
||||
"config",
|
||||
"config/cert.pem",
|
||||
"config/key.pem",
|
||||
})
|
||||
}
|
||||
|
||||
func beforeEachCreateTestDir(t *testing.T, dir string) string {
|
||||
// Make sure opened file (from the previous test) is cleared.
|
||||
closeLogFile()
|
||||
|
||||
dir = filepath.Join(testLogDir, dir)
|
||||
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||
return dir
|
||||
}
|
||||
|
||||
func checkLogFiles(t *testing.T, dir string, expectedCount int) []os.FileInfo {
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedCount, len(files))
|
||||
return files
|
||||
}
|
||||
|
||||
func checkLogContains(t *testing.T, dir, fileName, expectedSubstr string) {
|
||||
data, err := ioutil.ReadFile(filepath.Join(dir, fileName)) //nolint[gosec]
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), expectedSubstr)
|
||||
}
|
||||
76
pkg/config/mock_config.go
Normal file
76
pkg/config/mock_config.go
Normal file
@ -0,0 +1,76 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: config/config.go
|
||||
|
||||
// Package config is a generated GoMock package.
|
||||
package config
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
// MockappDirer is a mock of appDirer interface
|
||||
type MockappDirer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockappDirerMockRecorder
|
||||
}
|
||||
|
||||
// MockappDirerMockRecorder is the mock recorder for MockappDirer
|
||||
type MockappDirerMockRecorder struct {
|
||||
mock *MockappDirer
|
||||
}
|
||||
|
||||
// NewMockappDirer creates a new mock instance
|
||||
func NewMockappDirer(ctrl *gomock.Controller) *MockappDirer {
|
||||
mock := &MockappDirer{ctrl: ctrl}
|
||||
mock.recorder = &MockappDirerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockappDirer) EXPECT() *MockappDirerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// UserConfig mocks base method
|
||||
func (m *MockappDirer) UserConfig() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UserConfig")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UserConfig indicates an expected call of UserConfig
|
||||
func (mr *MockappDirerMockRecorder) UserConfig() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserConfig", reflect.TypeOf((*MockappDirer)(nil).UserConfig))
|
||||
}
|
||||
|
||||
// UserCache mocks base method
|
||||
func (m *MockappDirer) UserCache() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UserCache")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UserCache indicates an expected call of UserCache
|
||||
func (mr *MockappDirerMockRecorder) UserCache() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCache", reflect.TypeOf((*MockappDirer)(nil).UserCache))
|
||||
}
|
||||
|
||||
// UserLogs mocks base method
|
||||
func (m *MockappDirer) UserLogs() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UserLogs")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UserLogs indicates an expected call of UserLogs
|
||||
func (mr *MockappDirerMockRecorder) UserLogs() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserLogs", reflect.TypeOf((*MockappDirer)(nil).UserLogs))
|
||||
}
|
||||
127
pkg/config/preferences.go
Normal file
127
pkg/config/preferences.go
Normal file
@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Preferences struct {
|
||||
cache map[string]string
|
||||
path string
|
||||
lock *sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPreferences returns loaded preferences.
|
||||
func NewPreferences(preferencesPath string) *Preferences {
|
||||
p := &Preferences{
|
||||
path: preferencesPath,
|
||||
lock: &sync.RWMutex{},
|
||||
}
|
||||
if err := p.load(); err != nil {
|
||||
log.Warn("Cannot load preferences: ", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Preferences) load() error {
|
||||
if p.cache != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
p.cache = map[string]string{}
|
||||
|
||||
f, err := os.Open(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() //nolint[errcheck]
|
||||
|
||||
return json.NewDecoder(f).Decode(&p.cache)
|
||||
}
|
||||
|
||||
func (p *Preferences) save() error {
|
||||
if p.cache == nil {
|
||||
return errors.New("cannot save preferences: cache is nil")
|
||||
}
|
||||
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
f, err := os.Create(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() //nolint[errcheck]
|
||||
|
||||
return json.NewEncoder(f).Encode(p.cache)
|
||||
}
|
||||
|
||||
func (p *Preferences) SetDefault(key, value string) {
|
||||
if p.Get(key) == "" {
|
||||
p.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preferences) Get(key string) string {
|
||||
p.lock.RLock()
|
||||
defer p.lock.RUnlock()
|
||||
|
||||
return p.cache[key]
|
||||
}
|
||||
|
||||
func (p *Preferences) GetBool(key string) bool {
|
||||
return p.Get(key) == "true"
|
||||
}
|
||||
|
||||
func (p *Preferences) GetInt(key string) int {
|
||||
value, err := strconv.Atoi(p.Get(key))
|
||||
if err != nil {
|
||||
log.Error("Cannot parse int: ", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (p *Preferences) Set(key, value string) {
|
||||
p.lock.Lock()
|
||||
p.cache[key] = value
|
||||
p.lock.Unlock()
|
||||
|
||||
if err := p.save(); err != nil {
|
||||
log.Warn("Cannot save preferences: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preferences) SetBool(key string, value bool) {
|
||||
if value {
|
||||
p.Set(key, "true")
|
||||
} else {
|
||||
p.Set(key, "false")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preferences) SetInt(key string, value int) {
|
||||
p.Set(key, strconv.Itoa(value))
|
||||
}
|
||||
109
pkg/config/preferences_test.go
Normal file
109
pkg/config/preferences_test.go
Normal file
@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testPrefFilePath = "/tmp/pref.json"
|
||||
|
||||
func shutdownTestPreferences() {
|
||||
_ = os.RemoveAll(testPrefFilePath)
|
||||
}
|
||||
|
||||
func TestLoadNoPreferences(t *testing.T) {
|
||||
pref := newTestEmptyPreferences(t)
|
||||
require.Equal(t, "", pref.Get("key"))
|
||||
}
|
||||
|
||||
func TestLoadBadPreferences(t *testing.T) {
|
||||
require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"key\":\"value"), 0700))
|
||||
pref := NewPreferences(testPrefFilePath)
|
||||
require.Equal(t, "", pref.Get("key"))
|
||||
}
|
||||
|
||||
func TestPreferencesGet(t *testing.T) {
|
||||
pref := newTestPreferences(t)
|
||||
require.Equal(t, "value", pref.Get("str"))
|
||||
require.Equal(t, "42", pref.Get("int"))
|
||||
require.Equal(t, "true", pref.Get("bool"))
|
||||
require.Equal(t, "t", pref.Get("falseBool"))
|
||||
}
|
||||
|
||||
func TestPreferencesGetInt(t *testing.T) {
|
||||
pref := newTestPreferences(t)
|
||||
require.Equal(t, 0, pref.GetInt("str"))
|
||||
require.Equal(t, 42, pref.GetInt("int"))
|
||||
require.Equal(t, 0, pref.GetInt("bool"))
|
||||
require.Equal(t, 0, pref.GetInt("falseBool"))
|
||||
}
|
||||
|
||||
func TestPreferencesGetBool(t *testing.T) {
|
||||
pref := newTestPreferences(t)
|
||||
require.Equal(t, false, pref.GetBool("str"))
|
||||
require.Equal(t, false, pref.GetBool("int"))
|
||||
require.Equal(t, true, pref.GetBool("bool"))
|
||||
require.Equal(t, false, pref.GetBool("falseBool"))
|
||||
}
|
||||
|
||||
func TestPreferencesSetDefault(t *testing.T) {
|
||||
pref := newTestEmptyPreferences(t)
|
||||
pref.SetDefault("key", "value")
|
||||
pref.SetDefault("key", "othervalue")
|
||||
require.Equal(t, "value", pref.Get("key"))
|
||||
}
|
||||
|
||||
func TestPreferencesSet(t *testing.T) {
|
||||
pref := newTestEmptyPreferences(t)
|
||||
pref.Set("str", "value")
|
||||
checkSavedPreferences(t, "{\"str\":\"value\"}")
|
||||
}
|
||||
|
||||
func TestPreferencesSetInt(t *testing.T) {
|
||||
pref := newTestEmptyPreferences(t)
|
||||
pref.SetInt("int", 42)
|
||||
checkSavedPreferences(t, "{\"int\":\"42\"}")
|
||||
}
|
||||
|
||||
func TestPreferencesSetBool(t *testing.T) {
|
||||
pref := newTestEmptyPreferences(t)
|
||||
pref.SetBool("trueBool", true)
|
||||
pref.SetBool("falseBool", false)
|
||||
checkSavedPreferences(t, "{\"falseBool\":\"false\",\"trueBool\":\"true\"}")
|
||||
}
|
||||
|
||||
func newTestEmptyPreferences(t *testing.T) *Preferences {
|
||||
require.NoError(t, os.RemoveAll(testPrefFilePath))
|
||||
return NewPreferences(testPrefFilePath)
|
||||
}
|
||||
|
||||
func newTestPreferences(t *testing.T) *Preferences {
|
||||
require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"str\":\"value\",\"int\":\"42\",\"bool\":\"true\",\"falseBool\":\"t\"}"), 0700))
|
||||
return NewPreferences(testPrefFilePath)
|
||||
}
|
||||
|
||||
func checkSavedPreferences(t *testing.T, expected string) {
|
||||
data, err := ioutil.ReadFile(testPrefFilePath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected+"\n", string(data))
|
||||
}
|
||||
170
pkg/config/tls.go
Normal file
170
pkg/config/tls.go
Normal file
@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/osext"
|
||||
)
|
||||
|
||||
type tlsConfiger interface {
|
||||
GetTLSCertPath() string
|
||||
GetTLSKeyPath() string
|
||||
}
|
||||
|
||||
var tlsTemplate = x509.Certificate{ //nolint[gochecknoglobals]
|
||||
SerialNumber: big.NewInt(-1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"CH"},
|
||||
Organization: []string{"Proton Technologies AG"},
|
||||
OrganizationalUnit: []string{"ProtonMail"},
|
||||
CommonName: "127.0.0.1",
|
||||
},
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(20 * 365 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
var ErrTLSCertExpireSoon = fmt.Errorf("TLS certificate will expire soon")
|
||||
|
||||
// GetTLSConfig tries to load TLS config or generate new one which is then returned.
|
||||
func GetTLSConfig(cfg tlsConfiger) (tlsConfig *tls.Config, err error) {
|
||||
certPath := cfg.GetTLSCertPath()
|
||||
keyPath := cfg.GetTLSKeyPath()
|
||||
tlsConfig, err = loadTLSConfig(certPath, keyPath)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Cannot load cert, generating a new one")
|
||||
tlsConfig, err = generateTLSConfig(certPath, keyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
// If this fails, log the error but continue to load.
|
||||
if p, err := osext.Executable(); err == nil {
|
||||
p = strings.TrimSuffix(p, "MacOS/Desktop-Bridge") // This needs to match the executable name.
|
||||
p += "Resources/addcert.scpt"
|
||||
if err := exec.Command("/usr/bin/osascript", p).Run(); err != nil { // nolint[gosec]
|
||||
log.WithError(err).Error("Failed to add cert to system keychain")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig.ServerName = "127.0.0.1"
|
||||
tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AddCert(tlsConfig.Certificates[0].Leaf)
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
tlsConfig.ClientCAs = caCertPool
|
||||
|
||||
/* This is deprecated:
|
||||
* SA1019: tlsConfig.BuildNameToCertificate is deprecated:
|
||||
* NameToCertificate only allows associating a single certificate with a given name.
|
||||
* Leave that field nil to let the library select the first compatible chain from Certificates.
|
||||
*/
|
||||
tlsConfig.BuildNameToCertificate() // nolint[staticcheck]
|
||||
|
||||
return tlsConfig, err
|
||||
}
|
||||
|
||||
func loadTLSConfig(certPath, keyPath string) (tlsConfig *tls.Config, err error) {
|
||||
c, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.Leaf, err = x509.ParseCertificate(c.Certificate[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{c},
|
||||
}
|
||||
|
||||
if time.Now().Add(31 * 24 * time.Hour).After(c.Leaf.NotAfter) {
|
||||
err = ErrTLSCertExpireSoon
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// See https://golang.org/src/crypto/tls/generate_cert.go
|
||||
func generateTLSConfig(certPath, keyPath string) (tlsConfig *tls.Config, err error) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to generate private key: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to generate serial number: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
tlsTemplate.SerialNumber = serialNumber
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &tlsTemplate, &tlsTemplate, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to create certificate: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
certOut, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer certOut.Close() //nolint[errcheck]
|
||||
err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer keyOut.Close() //nolint[errcheck]
|
||||
err = pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return loadTLSConfig(certPath, keyPath)
|
||||
}
|
||||
63
pkg/config/tls_test.go
Normal file
63
pkg/config/tls_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2020 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 config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testTLSConfig struct{ certPath, keyPath string }
|
||||
|
||||
func (c *testTLSConfig) GetTLSCertPath() string { return c.certPath }
|
||||
func (c *testTLSConfig) GetTLSKeyPath() string { return c.keyPath }
|
||||
|
||||
func TestTLSKeyRenewal(t *testing.T) {
|
||||
// Remove keys.
|
||||
configPath := "/tmp"
|
||||
certPath := filepath.Join(configPath, "cert.pem")
|
||||
keyPath := filepath.Join(configPath, "key.pem")
|
||||
_ = os.Remove(certPath)
|
||||
_ = os.Remove(keyPath)
|
||||
|
||||
// Put old key there.
|
||||
tlsTemplate.NotBefore = time.Now().Add(-365 * 24 * time.Hour)
|
||||
tlsTemplate.NotAfter = time.Now()
|
||||
cert, err := generateTLSConfig(certPath, keyPath)
|
||||
require.Equal(t, err, ErrTLSCertExpireSoon)
|
||||
require.Equal(t, len(cert.Certificates), 1)
|
||||
time.Sleep(time.Second)
|
||||
now, notValidAfter := time.Now(), cert.Certificates[0].Leaf.NotAfter
|
||||
require.True(t, now.After(notValidAfter), "old certificate expected to not be valid at %v but have valid until %v", now, notValidAfter)
|
||||
|
||||
// Renew key.
|
||||
tlsTemplate.NotBefore = time.Now()
|
||||
tlsTemplate.NotAfter = time.Now().Add(2 * 365 * 24 * time.Hour)
|
||||
cert, err = GetTLSConfig(&testTLSConfig{certPath, keyPath})
|
||||
if runtime.GOOS != "darwin" { // Darwin is not supported.
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, len(cert.Certificates), 1)
|
||||
now, notValidAfter = time.Now(), cert.Certificates[0].Leaf.NotAfter
|
||||
require.False(t, now.After(notValidAfter), "new certificate expected to be valid at %v but have valid until %v", now, notValidAfter)
|
||||
}
|
||||
88
pkg/connection/check_connection.go
Normal file
88
pkg/connection/check_connection.go
Normal file
@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2020 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 connection
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/config"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
)
|
||||
|
||||
// Errors for possible connection issues
|
||||
var (
|
||||
ErrNoInternetConnection = errors.New("no internet connection")
|
||||
ErrCanNotReachAPI = errors.New("can not reach PM API")
|
||||
log = config.GetLogEntry("connection") //nolint[gochecknoglobals]
|
||||
)
|
||||
|
||||
// CheckInternetConnection does a check of API connection. It checks two of our endpoints in parallel.
|
||||
// One endpoint is part of the protonmail API, while the other is not.
|
||||
// This allows us to determine whether there is a problem with the connection itself or only a problem with our API.
|
||||
// Two errors can be returned, ErrNoInternetConnection or ErrCanNotReachAPI.
|
||||
func CheckInternetConnection() error {
|
||||
client := &http.Client{
|
||||
Transport: pmapi.NewPMAPIPinning(pmapi.CurrentUserAgent).TransportWithPinning(),
|
||||
}
|
||||
|
||||
// Do not cumulate timeouts, use goroutines.
|
||||
retStatus := make(chan error)
|
||||
retAPI := make(chan error)
|
||||
|
||||
// Check protonstatus.com without SSL for performance reasons. vpn_status endpoint is fast and
|
||||
// returns only OK; this endpoint is not known by the public. We check the connection only.
|
||||
go checkConnection(client, "http://protonstatus.com/vpn_status", retStatus)
|
||||
|
||||
// Check of API reachability also uses a fast endpoint.
|
||||
go checkConnection(client, pmapi.GlobalGetRootURL()+"/tests/ping", retAPI)
|
||||
|
||||
errStatus := <-retStatus
|
||||
errAPI := <-retAPI
|
||||
|
||||
if errStatus != nil {
|
||||
if errAPI != nil {
|
||||
log.Error("Checking internet connection failed with ", errStatus, " and ", errAPI)
|
||||
return ErrNoInternetConnection
|
||||
}
|
||||
log.Warning("API OK, but status: ", errStatus)
|
||||
return nil
|
||||
}
|
||||
|
||||
if errAPI != nil {
|
||||
log.Error("Status OK, but API: ", errAPI)
|
||||
return ErrCanNotReachAPI
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
91
pkg/connection/check_connection_test.go
Normal file
91
pkg/connection/check_connection_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2020 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 connection
|
||||
|
||||
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(10 * time.Second)
|
||||
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")
|
||||
}
|
||||
}
|
||||
46
pkg/dialer/dial_client.go
Normal file
46
pkg/dialer/dial_client.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2020 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 dialer
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ClientTimeout is the timeout for the whole request (from dial to
|
||||
// receiving the response body). It should be large enough to download
|
||||
// even the largest attachments or the new binary of the Bridge, but
|
||||
// should be hit if the server hangs (default is infinite which is bad).
|
||||
clientTimeout = 30 * time.Minute
|
||||
dialTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// DialTimeoutClient creates client with overridden dialTimeout.
|
||||
func DialTimeoutClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
Dial: func(network, addr string) (net.Conn, error) {
|
||||
return net.DialTimeout(network, addr, dialTimeout)
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: clientTimeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
131
pkg/keychain/keychain.go
Normal file
131
pkg/keychain/keychain.go
Normal file
@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2020 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 keychain implements a native secure password store for each platform.
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/config"
|
||||
"github.com/docker/docker-credential-helpers/credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
KeychainVersion = "k11" //nolint[golint]
|
||||
)
|
||||
|
||||
var (
|
||||
log = config.GetLogEntry("bridgeUtils/keychain") //nolint[gochecknoglobals]
|
||||
|
||||
ErrWrongKeychainURL = errors.New("wrong keychain base URL")
|
||||
ErrMacKeychainRebuild = errors.New("keychain error -25293")
|
||||
ErrMacKeychainList = errors.New("function `osxkeychain.List()` is not valid function for mac keychain. Use `Access.ListKeychain()` instead")
|
||||
ErrNoKeychainInstalled = errors.New("no keychain management installed on this system")
|
||||
accessLocker = &sync.Mutex{} //nolint[gochecknoglobals]
|
||||
)
|
||||
|
||||
// NewAccess creates a new native keychain.
|
||||
func NewAccess(appName string) (*Access, error) {
|
||||
newHelper, err := newKeychain()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Access{
|
||||
helper: newHelper,
|
||||
KeychainURL: "protonmail/" + appName + "/users",
|
||||
KeychainOldURL: "protonmail/users",
|
||||
KeychainMacURL: "ProtonMail" + strings.Title(appName) + "Service",
|
||||
KeychainOldMacURL: "ProtonMailService",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Access struct {
|
||||
helper credentials.Helper
|
||||
KeychainURL,
|
||||
KeychainOldURL,
|
||||
KeychainMacURL,
|
||||
KeychainOldMacURL string
|
||||
}
|
||||
|
||||
func (s *Access) List() (userIDs []string, err error) {
|
||||
accessLocker.Lock()
|
||||
defer accessLocker.Unlock()
|
||||
|
||||
var userIDByURL map[string]string
|
||||
userIDByURL, err = s.ListKeychain()
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for itemURL, userID := range userIDByURL {
|
||||
if itemURL == s.KeychainName(userID) {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
|
||||
// Clean up old keychain name.
|
||||
if itemURL == s.KeychainOldName(userID) {
|
||||
_ = s.helper.Delete(s.KeychainOldName(userID))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Access) Delete(userID string) (err error) {
|
||||
accessLocker.Lock()
|
||||
defer accessLocker.Unlock()
|
||||
return s.helper.Delete(s.KeychainName(userID))
|
||||
}
|
||||
|
||||
func (s *Access) Get(userID string) (secret string, err error) {
|
||||
accessLocker.Lock()
|
||||
defer accessLocker.Unlock()
|
||||
_, secret, err = s.helper.Get(s.KeychainName(userID))
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Access) Put(userID, secret string) error {
|
||||
accessLocker.Lock()
|
||||
defer accessLocker.Unlock()
|
||||
|
||||
// On macOS, adding a credential that already exists does not update it and returns an error.
|
||||
// So let's remove it first.
|
||||
_ = s.helper.Delete(s.KeychainName(userID))
|
||||
|
||||
cred := &credentials.Credentials{
|
||||
ServerURL: s.KeychainName(userID),
|
||||
Username: userID,
|
||||
Secret: secret,
|
||||
}
|
||||
|
||||
return s.helper.Add(cred)
|
||||
}
|
||||
|
||||
func splitServiceAndID(keychainName string) (serviceName string, userID string, err error) { //nolint[unused]
|
||||
splitted := strings.FieldsFunc(keychainName, func(c rune) bool { return c == '/' })
|
||||
n := len(splitted)
|
||||
if n <= 1 {
|
||||
return "", "", ErrWrongKeychainURL
|
||||
}
|
||||
userID = splitted[len(splitted)-1]
|
||||
serviceName = strings.Join(splitted[:len(splitted)-1], "/")
|
||||
return
|
||||
}
|
||||
140
pkg/keychain/keychain_darwin.go
Normal file
140
pkg/keychain/keychain_darwin.go
Normal file
@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2020 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 keychain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker-credential-helpers/credentials"
|
||||
mackeychain "github.com/keybase/go-keychain"
|
||||
)
|
||||
|
||||
func (s *Access) KeychainName(userID string) string {
|
||||
return s.KeychainMacURL + "/" + userID
|
||||
}
|
||||
|
||||
func (s *Access) KeychainOldName(userID string) string {
|
||||
return s.KeychainOldMacURL + "/" + userID
|
||||
}
|
||||
|
||||
type osxkeychain struct {
|
||||
}
|
||||
|
||||
func newKeychain() (credentials.Helper, error) {
|
||||
log.Debug("creating osckeychain")
|
||||
return &osxkeychain{}, nil
|
||||
}
|
||||
|
||||
func newQuery(serviceName, username string) mackeychain.Item {
|
||||
query := mackeychain.NewItem()
|
||||
query.SetSecClass(mackeychain.SecClassGenericPassword)
|
||||
query.SetService(serviceName)
|
||||
query.SetAccount(username)
|
||||
return query
|
||||
}
|
||||
|
||||
func parseError(original error) error {
|
||||
if original != nil && strings.Contains(original.Error(), "25293") {
|
||||
return ErrMacKeychainRebuild
|
||||
}
|
||||
return original
|
||||
}
|
||||
|
||||
// Add appends credentials to the store (assuming old record with same ID is already deleted).
|
||||
func (s *osxkeychain) Add(cred *credentials.Credentials) error {
|
||||
serviceName, userID, err := splitServiceAndID(cred.ServerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := newQuery(serviceName, userID)
|
||||
query.SetData([]byte(cred.Secret))
|
||||
err = mackeychain.AddItem(query)
|
||||
return parseError(err)
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store.
|
||||
func (s *osxkeychain) Delete(serverURL string) error {
|
||||
serviceName, userID, err := splitServiceAndID(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := newQuery(serviceName, userID)
|
||||
err = mackeychain.DeleteItem(query)
|
||||
if err != nil && !strings.Contains(err.Error(), "25300") { // Missing item is not error.
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store.
|
||||
// It returns username and secret as strings.
|
||||
func (s *osxkeychain) Get(serverURL string) (userID string, secret string, err error) {
|
||||
serviceName, userID, err := splitServiceAndID(serverURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
query := newQuery(serviceName, userID)
|
||||
query.SetMatchLimit(mackeychain.MatchLimitOne)
|
||||
query.SetReturnData(true)
|
||||
results, err := mackeychain.QueryItem(query)
|
||||
if err != nil {
|
||||
return "", "", parseError(err)
|
||||
}
|
||||
|
||||
if len(results) == 1 {
|
||||
secret = string(results[0].Data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ListKeychain lists items in our services.
|
||||
func (s *Access) ListKeychain() (userIDByURL map[string]string, err error) {
|
||||
// Pick up correct service name and trim '/'.
|
||||
serviceName, _, err := splitServiceAndID(s.KeychainOldName("not-id"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
userIDByURL = make(map[string]string)
|
||||
|
||||
if oldIDs, err := mackeychain.GetGenericPasswordAccounts(serviceName); err == nil {
|
||||
for _, userIDold := range oldIDs {
|
||||
userIDByURL[s.KeychainOldName(userIDold)] = userIDold
|
||||
}
|
||||
}
|
||||
|
||||
serviceName, _, _ = splitServiceAndID(s.KeychainName("not-id"))
|
||||
if userIDs, err := mackeychain.GetGenericPasswordAccounts(serviceName); err == nil {
|
||||
for _, userID := range userIDs {
|
||||
userIDByURL[s.KeychainName(userID)] = userID
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// List returns the stored serverURLs and their associated usernames.
|
||||
// NOTE: This is not valid for go-keychain. Use ListKeychain instead.
|
||||
func (s *osxkeychain) List() (userIDByURL map[string]string, err error) {
|
||||
err = ErrMacKeychainList
|
||||
return
|
||||
}
|
||||
73
pkg/keychain/keychain_linux.go
Normal file
73
pkg/keychain/keychain_linux.go
Normal file
@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2020 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 keychain
|
||||
|
||||
import (
|
||||
"github.com/docker/docker-credential-helpers/credentials"
|
||||
"github.com/docker/docker-credential-helpers/pass"
|
||||
"github.com/docker/docker-credential-helpers/secretservice"
|
||||
)
|
||||
|
||||
func newKeychain() (credentials.Helper, error) {
|
||||
log.Debug("creating pass")
|
||||
passHelper := &pass.Pass{}
|
||||
passErr := checkPassIsUsable(passHelper)
|
||||
if passErr == nil {
|
||||
return passHelper, nil
|
||||
}
|
||||
|
||||
log.Debug("creating secretservice")
|
||||
sserviceHelper := &secretservice.Secretservice{}
|
||||
_, sserviceErr := sserviceHelper.List()
|
||||
if sserviceErr == nil {
|
||||
return sserviceHelper, nil
|
||||
}
|
||||
|
||||
log.Error("No keychain! Pass: ", passErr, ", secretService: ", sserviceErr)
|
||||
return nil, ErrNoKeychainInstalled
|
||||
}
|
||||
|
||||
func checkPassIsUsable(passHelper *pass.Pass) (err error) {
|
||||
creds := &credentials.Credentials{
|
||||
ServerURL: "initCheck/pass",
|
||||
Username: "pass",
|
||||
Secret: "pass",
|
||||
}
|
||||
|
||||
if err = passHelper.Add(creds); err != nil {
|
||||
return
|
||||
}
|
||||
// Pass is not asked about unlock until you try to decrypt.
|
||||
if _, _, err = passHelper.Get(creds.ServerURL); err != nil {
|
||||
return
|
||||
}
|
||||
_ = passHelper.Delete(creds.ServerURL) // Doesn't matter if you are able to clear.
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Access) KeychainName(userID string) string {
|
||||
return s.KeychainURL + "/" + userID
|
||||
}
|
||||
|
||||
func (s *Access) KeychainOldName(userID string) string {
|
||||
return s.KeychainOldURL + "/" + userID
|
||||
}
|
||||
|
||||
func (s *Access) ListKeychain() (map[string]string, error) {
|
||||
return s.helper.List()
|
||||
}
|
||||
152
pkg/keychain/keychain_test.go
Normal file
152
pkg/keychain/keychain_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2020 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 keychain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var suffix = []byte("\x00avoidFix\x00\x00\x00\x00\x00\x00\x00") //nolint[gochecknoglobals]
|
||||
|
||||
var testData = map[string]string{ //nolint[gochecknoglobals]
|
||||
"user1": base64.StdEncoding.EncodeToString(append([]byte("data1"), suffix...)),
|
||||
"user2": base64.StdEncoding.EncodeToString(append([]byte("data2"), suffix...)),
|
||||
}
|
||||
|
||||
func TestSplitServiceAndID(t *testing.T) {
|
||||
acc, err := NewAccess("bridge")
|
||||
require.NoError(t, err)
|
||||
expectedUserID := "user"
|
||||
|
||||
acc.KeychainURL = "Something/With/Several/Slashes/"
|
||||
acc.KeychainMacURL = acc.KeychainURL
|
||||
expectedServiceName := acc.KeychainURL
|
||||
serviceName, userID, err := splitServiceAndID(acc.KeychainName(expectedUserID))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedUserID, userID)
|
||||
require.Equal(t, expectedServiceName, serviceName+"/")
|
||||
|
||||
acc.KeychainURL = "SomethingWithoutSlash"
|
||||
acc.KeychainMacURL = acc.KeychainURL
|
||||
expectedServiceName = acc.KeychainURL
|
||||
serviceName, userID, err = splitServiceAndID(acc.KeychainName(expectedUserID))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedUserID, userID)
|
||||
require.Equal(t, expectedServiceName, serviceName)
|
||||
}
|
||||
|
||||
func TestInsertReadRemove(t *testing.T) { // nolint[funlen]
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
access, err := NewAccess("bridge")
|
||||
require.NoError(t, err)
|
||||
access.KeychainURL = "protonmail/testchain/users"
|
||||
access.KeychainMacURL = "ProtonMailTestChainService"
|
||||
|
||||
// Clear before test.
|
||||
for id := range testData {
|
||||
// Keychain can be empty.
|
||||
_ = access.Delete(id)
|
||||
}
|
||||
|
||||
for id, secret := range testData {
|
||||
expectedList, _ := access.List()
|
||||
// Add expected secrets.
|
||||
expectedSecret := secret
|
||||
require.NoError(t, access.Put(id, expectedSecret))
|
||||
|
||||
// Check list.
|
||||
actualList, err := access.List()
|
||||
require.NoError(t, err)
|
||||
expectedList = append(expectedList, id)
|
||||
require.ElementsMatch(t, expectedList, actualList)
|
||||
|
||||
// Get and check what was inserted.
|
||||
actualSecret, err := access.Get(id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedSecret, actualSecret)
|
||||
|
||||
// Put what changed.
|
||||
|
||||
expectedSecret = "edited_" + id
|
||||
expectedSecret = base64.StdEncoding.EncodeToString(append([]byte(expectedSecret), suffix...))
|
||||
|
||||
nJobs := 100
|
||||
nWorkers := 3
|
||||
jobs := make(chan interface{}, nJobs)
|
||||
done := make(chan interface{})
|
||||
for i := 0; i < nWorkers; i++ {
|
||||
go func() {
|
||||
for {
|
||||
_, more := <-jobs
|
||||
if more {
|
||||
require.NoError(t, access.Put(id, expectedSecret))
|
||||
} else {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < nJobs; i++ {
|
||||
jobs <- nil
|
||||
}
|
||||
close(jobs)
|
||||
for i := 0; i < nWorkers; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Check list.
|
||||
actualList, err = access.List()
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, expectedList, actualList)
|
||||
|
||||
// Get and check what changed.
|
||||
actualSecret, err = access.Get(id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedSecret, actualSecret)
|
||||
|
||||
if id != "user1" {
|
||||
// Remove.
|
||||
err = access.Delete(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check removed.
|
||||
actualList, err = access.List()
|
||||
require.NoError(t, err)
|
||||
expectedList = expectedList[:len(expectedList)-1]
|
||||
require.ElementsMatch(t, expectedList, actualList)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear first.
|
||||
err = access.Delete("user1")
|
||||
require.NoError(t, err)
|
||||
|
||||
actualList, err := access.List()
|
||||
require.NoError(t, err)
|
||||
for id := range testData {
|
||||
require.NotContains(t, actualList, id)
|
||||
}
|
||||
}
|
||||
40
pkg/keychain/keychain_windows.go
Normal file
40
pkg/keychain/keychain_windows.go
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2020 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 keychain
|
||||
|
||||
import (
|
||||
"github.com/docker/docker-credential-helpers/credentials"
|
||||
"github.com/docker/docker-credential-helpers/wincred"
|
||||
)
|
||||
|
||||
func newKeychain() (credentials.Helper, error) {
|
||||
log.Debug("creating wincred")
|
||||
return &wincred.Wincred{}, nil
|
||||
}
|
||||
|
||||
func (s *Access) KeychainName(userID string) string {
|
||||
return s.KeychainURL + "/" + userID
|
||||
}
|
||||
|
||||
func (s *Access) KeychainOldName(userID string) string {
|
||||
return s.KeychainOldURL + "/" + userID
|
||||
}
|
||||
|
||||
func (s *Access) ListKeychain() (map[string]string, error) {
|
||||
return s.helper.List()
|
||||
}
|
||||
180
pkg/listener/listener.go
Normal file
180
pkg/listener/listener.go
Normal file
@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2020 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 listener
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/config"
|
||||
)
|
||||
|
||||
var log = config.GetLogEntry("bridgeUtils/listener") //nolint[gochecknoglobals]
|
||||
|
||||
// Listener has a list of channels watching for updates.
|
||||
type Listener interface {
|
||||
SetLimit(eventName string, limit time.Duration)
|
||||
Add(eventName string, channel chan<- string)
|
||||
Remove(eventName string, channel chan<- string)
|
||||
Emit(eventName string, data string)
|
||||
SetBuffer(eventName string)
|
||||
RetryEmit(eventName string)
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
channels map[string][]chan<- string
|
||||
limits map[string]time.Duration
|
||||
lastEmits map[string]map[string]time.Time
|
||||
buffered map[string][]string
|
||||
lock *sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns a new Listener which initially has no topics.
|
||||
func New() Listener {
|
||||
return &listener{
|
||||
channels: nil,
|
||||
limits: make(map[string]time.Duration),
|
||||
lastEmits: make(map[string]map[string]time.Time),
|
||||
buffered: make(map[string][]string),
|
||||
lock: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit sets the limit for the `eventName`. When the same event (name and data)
|
||||
// is emitted within last time duration (`limit`), event is dropped. Zero limit clears
|
||||
// the limit for the specific `eventName`.
|
||||
func (l *listener) SetLimit(eventName string, limit time.Duration) {
|
||||
if limit == 0 {
|
||||
delete(l.limits, eventName)
|
||||
return
|
||||
}
|
||||
l.limits[eventName] = limit
|
||||
}
|
||||
|
||||
// Add adds an event listener.
|
||||
func (l *listener) Add(eventName string, channel chan<- string) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
if l.channels == nil {
|
||||
l.channels = make(map[string][]chan<- string)
|
||||
}
|
||||
if _, ok := l.channels[eventName]; ok {
|
||||
l.channels[eventName] = append(l.channels[eventName], channel)
|
||||
} else {
|
||||
l.channels[eventName] = []chan<- string{channel}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes an event listener.
|
||||
func (l *listener) Remove(eventName string, channel chan<- string) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
if _, ok := l.channels[eventName]; ok {
|
||||
for i := range l.channels[eventName] {
|
||||
if l.channels[eventName][i] == channel {
|
||||
l.channels[eventName] = append(l.channels[eventName][:i], l.channels[eventName][i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit emits an event in parallel to all listeners (channels).
|
||||
func (l *listener) Emit(eventName string, data string) {
|
||||
l.emit(eventName, data, false)
|
||||
}
|
||||
|
||||
func (l *listener) emit(eventName, data string, isReEmit bool) {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
if !l.shouldEmit(eventName, data) {
|
||||
log.Warn("Emit of ", eventName, " with data ", data, " skipped")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := l.channels[eventName]; ok {
|
||||
for i, handler := range l.channels[eventName] {
|
||||
go func(handler chan<- string, i int) {
|
||||
handler <- data
|
||||
log.Debugf("emitted %s data %s -> %d", eventName, data, i)
|
||||
}(handler, i)
|
||||
}
|
||||
} else if !isReEmit {
|
||||
if bufferedData, ok := l.buffered[eventName]; ok {
|
||||
l.buffered[eventName] = append(bufferedData, data)
|
||||
log.Debugf("Buffering event %s data %s", eventName, data)
|
||||
} else {
|
||||
log.Warnf("No channel is listening to %s data %s", eventName, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *listener) shouldEmit(eventName, data string) bool {
|
||||
if _, ok := l.limits[eventName]; !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
l.clearLastEmits()
|
||||
|
||||
if eventLastEmits, ok := l.lastEmits[eventName]; ok {
|
||||
if _, ok := eventLastEmits[data]; ok {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
l.lastEmits[eventName] = make(map[string]time.Time)
|
||||
}
|
||||
|
||||
l.lastEmits[eventName][data] = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *listener) clearLastEmits() {
|
||||
for eventName, lastEmits := range l.lastEmits {
|
||||
limit, ok := l.limits[eventName]
|
||||
if !ok { // Limits were disabled.
|
||||
delete(l.lastEmits, eventName)
|
||||
continue
|
||||
}
|
||||
for key, lastEmit := range lastEmits {
|
||||
if time.Since(lastEmit) > limit {
|
||||
delete(lastEmits, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *listener) SetBuffer(eventName string) {
|
||||
if _, ok := l.buffered[eventName]; !ok {
|
||||
l.buffered[eventName] = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *listener) RetryEmit(eventName string) {
|
||||
if _, ok := l.channels[eventName]; !ok || len(l.channels[eventName]) == 0 {
|
||||
return
|
||||
}
|
||||
if bufferedData, ok := l.buffered[eventName]; ok {
|
||||
for _, data := range bufferedData {
|
||||
l.emit(eventName, data, true)
|
||||
}
|
||||
l.buffered[eventName] = []string{}
|
||||
}
|
||||
}
|
||||
172
pkg/listener/listener_test.go
Normal file
172
pkg/listener/listener_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2020 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 listener
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
eventListener := New()
|
||||
|
||||
ch := make(chan string)
|
||||
eventListener.Add("eventname", ch)
|
||||
for eventdata := range ch {
|
||||
fmt.Println(eventdata + " world")
|
||||
}
|
||||
|
||||
eventListener.Emit("eventname", "hello")
|
||||
}
|
||||
|
||||
func TestAddAndEmitSameEvent(t *testing.T) {
|
||||
listener, channel := newListener()
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
}
|
||||
|
||||
func TestAddAndEmitDifferentEvent(t *testing.T) {
|
||||
listener, channel := newListener()
|
||||
|
||||
listener.Emit("other", "hello!")
|
||||
checkChannelNotEmitted(t, channel)
|
||||
}
|
||||
|
||||
func TestAddAndRemove(t *testing.T) {
|
||||
listener := New()
|
||||
|
||||
channel := make(chan string)
|
||||
listener.Add("event", channel)
|
||||
listener.Remove("event", channel)
|
||||
listener.Emit("event", "hello!")
|
||||
|
||||
checkChannelNotEmitted(t, channel)
|
||||
}
|
||||
|
||||
func TestNoLimit(t *testing.T) {
|
||||
listener, channel := newListener()
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
}
|
||||
|
||||
func TestLimit(t *testing.T) {
|
||||
listener, channel := newListener()
|
||||
listener.SetLimit("event", 1*time.Second)
|
||||
|
||||
channel2 := make(chan string)
|
||||
listener.Add("event", channel2)
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
checkChannelEmitted(t, channel2, "hello!")
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelNotEmitted(t, channel)
|
||||
checkChannelNotEmitted(t, channel2)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
checkChannelEmitted(t, channel2, "hello!")
|
||||
}
|
||||
|
||||
func TestLimitDifferentData(t *testing.T) {
|
||||
listener, channel := newListener()
|
||||
listener.SetLimit("event", 1*time.Second)
|
||||
|
||||
listener.Emit("event", "hello!")
|
||||
checkChannelEmitted(t, channel, "hello!")
|
||||
|
||||
listener.Emit("event", "hello?")
|
||||
checkChannelEmitted(t, channel, "hello?")
|
||||
}
|
||||
|
||||
func TestReEmit(t *testing.T) {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
listener := New()
|
||||
listener.Emit("event", "hello?")
|
||||
|
||||
listener.SetBuffer("event")
|
||||
listener.SetBuffer("other")
|
||||
|
||||
listener.Emit("event", "hello1")
|
||||
listener.Emit("event", "hello2")
|
||||
listener.Emit("other", "hello!")
|
||||
listener.Emit("event", "hello3")
|
||||
listener.Emit("other", "hello!")
|
||||
|
||||
eventCH := make(chan string, 3)
|
||||
listener.Add("event", eventCH)
|
||||
|
||||
otherCH := make(chan string)
|
||||
listener.Add("other", otherCH)
|
||||
|
||||
listener.RetryEmit("event")
|
||||
listener.RetryEmit("other")
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
receivedEvents := map[string]int{}
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case res := <-eventCH:
|
||||
receivedEvents[res]++
|
||||
case res := <-otherCH:
|
||||
receivedEvents[res+":other"]++
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
t.Fatalf("Channel not emitted %d times", i+1)
|
||||
}
|
||||
}
|
||||
expectedEvents := map[string]int{"hello1": 1, "hello2": 1, "hello3": 1, "hello!:other": 2}
|
||||
require.Equal(t, expectedEvents, receivedEvents)
|
||||
}
|
||||
|
||||
func newListener() (Listener, chan string) {
|
||||
listener := New()
|
||||
|
||||
channel := make(chan string)
|
||||
listener.Add("event", channel)
|
||||
|
||||
return listener, channel
|
||||
}
|
||||
|
||||
func checkChannelEmitted(t testing.TB, channel chan string, expectedData string) {
|
||||
select {
|
||||
case res := <-channel:
|
||||
require.Equal(t, expectedData, res)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
t.Fatalf("Channel not emitted with expected data: %s", expectedData)
|
||||
}
|
||||
}
|
||||
|
||||
func checkChannelNotEmitted(t testing.TB, channel chan string) {
|
||||
select {
|
||||
case res := <-channel:
|
||||
t.Fatalf("Channel emitted with a unexpected response: %s", res)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
56
pkg/message/address.go
Normal file
56
pkg/message/address.go
Normal file
@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
func getAddresses(addrs []*mail.Address) (imapAddrs []*imap.Address) {
|
||||
for _, a := range addrs {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(a.Address, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
imapAddrs = append(imapAddrs, &imap.Address{
|
||||
PersonalName: a.Name,
|
||||
MailboxName: parts[0],
|
||||
HostName: parts[1],
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func formatAddressList(addrs []*mail.Address) (s string) {
|
||||
for i, addr := range addrs {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
s += addr.String()
|
||||
}
|
||||
return
|
||||
}
|
||||
75
pkg/message/body.go
Normal file
75
pkg/message/body.go
Normal file
@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-textwrapper"
|
||||
openpgperrors "golang.org/x/crypto/openpgp/errors"
|
||||
)
|
||||
|
||||
func WriteBody(w io.Writer, kr *pmcrypto.KeyRing, m *pmapi.Message) error {
|
||||
// Decrypt body.
|
||||
if err := m.Decrypt(kr); err != nil && err != openpgperrors.ErrSignatureExpired {
|
||||
return err
|
||||
}
|
||||
if m.MIMEType != pmapi.ContentTypeMultipartMixed {
|
||||
// Encode it.
|
||||
qp := quotedprintable.NewWriter(w)
|
||||
if _, err := io.WriteString(qp, m.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return qp.Close()
|
||||
}
|
||||
_, err := io.WriteString(w, m.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func WriteAttachmentBody(w io.Writer, kr *pmcrypto.KeyRing, m *pmapi.Message, att *pmapi.Attachment, r io.Reader) (err error) {
|
||||
// Decrypt it
|
||||
var dr io.Reader
|
||||
dr, err = att.Decrypt(r, kr)
|
||||
if err == openpgperrors.ErrKeyIncorrect {
|
||||
// Do not fail if attachment is encrypted with a different key.
|
||||
dr = r
|
||||
err = nil
|
||||
att.Name += ".gpg"
|
||||
att.MIMEType = "application/pgp-encrypted"
|
||||
} else if err != nil && err != openpgperrors.ErrSignatureExpired {
|
||||
err = fmt.Errorf("cannot decrypt attachment: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Encode it.
|
||||
ww := textwrapper.NewRFC822(w)
|
||||
bw := base64.NewEncoder(base64.StdEncoding, ww)
|
||||
|
||||
var n int64
|
||||
if n, err = io.Copy(bw, dr); err != nil {
|
||||
err = fmt.Errorf("cannot write attachment: %v (wrote %v bytes)", err, n)
|
||||
}
|
||||
|
||||
_ = bw.Close()
|
||||
return
|
||||
}
|
||||
48
pkg/message/envelope.go
Normal file
48
pkg/message/envelope.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
func GetEnvelope(m *pmapi.Message) *imap.Envelope {
|
||||
messageID := m.ExternalID
|
||||
if messageID == "" {
|
||||
messageID = m.Header.Get("Message-Id")
|
||||
} else {
|
||||
messageID = "<" + messageID + ">"
|
||||
}
|
||||
|
||||
return &imap.Envelope{
|
||||
Date: time.Unix(m.Time, 0),
|
||||
Subject: m.Subject,
|
||||
From: getAddresses([]*mail.Address{m.Sender}),
|
||||
Sender: getAddresses([]*mail.Address{m.Sender}),
|
||||
ReplyTo: getAddresses(m.ReplyTos),
|
||||
To: getAddresses(m.ToList),
|
||||
Cc: getAddresses(m.CCList),
|
||||
Bcc: getAddresses(m.BCCList),
|
||||
InReplyTo: m.Header.Get("In-Reply-To"),
|
||||
MessageId: messageID,
|
||||
}
|
||||
}
|
||||
83
pkg/message/flags.go
Normal file
83
pkg/message/flags.go
Normal file
@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
//nolint[gochecknoglobals]
|
||||
var (
|
||||
AppleMailJunkFlag = imap.CanonicalFlag("$Junk")
|
||||
ThunderbirdJunkFlag = imap.CanonicalFlag("Junk")
|
||||
ThunderbirdNonJunkFlag = imap.CanonicalFlag("NonJunk")
|
||||
)
|
||||
|
||||
func GetFlags(m *pmapi.Message) (flags []string) {
|
||||
if m.Unread == 0 {
|
||||
flags = append(flags, imap.SeenFlag)
|
||||
}
|
||||
if !m.Has(pmapi.FlagSent) && !m.Has(pmapi.FlagReceived) {
|
||||
flags = append(flags, imap.DraftFlag)
|
||||
}
|
||||
if m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll) {
|
||||
flags = append(flags, imap.AnsweredFlag)
|
||||
}
|
||||
|
||||
hasSpam := false
|
||||
|
||||
for _, l := range m.LabelIDs {
|
||||
if l == pmapi.StarredLabel {
|
||||
flags = append(flags, imap.FlaggedFlag)
|
||||
}
|
||||
if l == pmapi.SpamLabel {
|
||||
flags = append(flags, AppleMailJunkFlag, ThunderbirdJunkFlag)
|
||||
hasSpam = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSpam {
|
||||
flags = append(flags, ThunderbirdNonJunkFlag)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func ParseFlags(m *pmapi.Message, flags []string) {
|
||||
if (m.Flags & pmapi.FlagSent) == 0 {
|
||||
m.Flags |= pmapi.FlagReceived
|
||||
}
|
||||
m.Unread = 1
|
||||
for _, f := range flags {
|
||||
switch f {
|
||||
case imap.SeenFlag:
|
||||
m.Unread = 0
|
||||
case imap.DraftFlag:
|
||||
m.Flags &= ^pmapi.FlagSent
|
||||
m.Flags &= ^pmapi.FlagReceived
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.DraftLabel)
|
||||
case imap.FlaggedFlag:
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.StarredLabel)
|
||||
case imap.AnsweredFlag:
|
||||
m.Flags |= pmapi.FlagReplied
|
||||
case AppleMailJunkFlag, ThunderbirdJunkFlag:
|
||||
m.LabelIDs = append(m.LabelIDs, pmapi.SpamLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
214
pkg/message/header.go
Normal file
214
pkg/message/header.go
Normal file
@ -0,0 +1,214 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
)
|
||||
|
||||
// GetHeader builds the header for the message.
|
||||
func GetHeader(msg *pmapi.Message) textproto.MIMEHeader { //nolint[funlen]
|
||||
h := make(textproto.MIMEHeader)
|
||||
|
||||
// Copy the custom header fields if there are some.
|
||||
if msg.Header != nil {
|
||||
h = textproto.MIMEHeader(msg.Header)
|
||||
}
|
||||
|
||||
// Add or rewrite fields.
|
||||
h.Set("Subject", pmmime.EncodeHeader(msg.Subject))
|
||||
if msg.Sender != nil {
|
||||
h.Set("From", pmmime.EncodeHeader(msg.Sender.String()))
|
||||
}
|
||||
if len(msg.ReplyTos) > 0 {
|
||||
h.Set("Reply-To", pmmime.EncodeHeader(formatAddressList(msg.ReplyTos)))
|
||||
}
|
||||
if len(msg.ToList) > 0 {
|
||||
h.Set("To", pmmime.EncodeHeader(formatAddressList(msg.ToList)))
|
||||
}
|
||||
if len(msg.CCList) > 0 {
|
||||
h.Set("Cc", pmmime.EncodeHeader(formatAddressList(msg.CCList)))
|
||||
}
|
||||
if len(msg.BCCList) > 0 {
|
||||
h.Set("Bcc", pmmime.EncodeHeader(formatAddressList(msg.BCCList)))
|
||||
}
|
||||
|
||||
// Add or rewrite date related fields.
|
||||
if msg.Time > 0 {
|
||||
h.Set("X-Pm-Date", time.Unix(msg.Time, 0).Format(time.RFC1123Z))
|
||||
if d, err := msg.Header.Date(); err != nil || d.IsZero() { // Fix date if needed.
|
||||
h.Set("Date", time.Unix(msg.Time, 0).Format(time.RFC1123Z))
|
||||
}
|
||||
}
|
||||
|
||||
// Use External-Id if available to ensure email clients:
|
||||
// * build the conversations threads correctly (Thunderbird, Mac Outlook, Apple Mail)
|
||||
// * do not think the message is lost (Apple Mail)
|
||||
if msg.ExternalID != "" {
|
||||
h.Set("X-Pm-External-Id", "<"+msg.ExternalID+">")
|
||||
if h.Get("Message-Id") == "" {
|
||||
h.Set("Message-Id", "<"+msg.ExternalID+">")
|
||||
}
|
||||
}
|
||||
if msg.ID != "" {
|
||||
if h.Get("Message-Id") == "" {
|
||||
h.Set("Message-Id", "<"+msg.ID+"@protonmail.internalid>")
|
||||
}
|
||||
h.Set("X-Pm-Internal-Id", msg.ID)
|
||||
// Forward References, and include the message ID here (to improve outlook support).
|
||||
if references := h.Get("References"); !strings.Contains(references, msg.ID) {
|
||||
references += " <" + msg.ID + "@protonmail.internalid>"
|
||||
h.Set("References", references)
|
||||
}
|
||||
}
|
||||
if msg.ConversationID != "" {
|
||||
h.Set("X-Pm-ConversationID-Id", msg.ConversationID)
|
||||
if references := h.Get("References"); !strings.Contains(references, msg.ConversationID) {
|
||||
references += " <" + msg.ConversationID + "@protonmail.conversationid>"
|
||||
h.Set("References", references)
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func SetBodyContentFields(h *textproto.MIMEHeader, m *pmapi.Message) {
|
||||
h.Set("Content-Type", m.MIMEType+"; charset=utf-8")
|
||||
h.Set("Content-Disposition", "inline")
|
||||
h.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
}
|
||||
|
||||
func GetBodyHeader(m *pmapi.Message) textproto.MIMEHeader {
|
||||
h := make(textproto.MIMEHeader)
|
||||
SetBodyContentFields(&h, m)
|
||||
return h
|
||||
}
|
||||
|
||||
func GetRelatedHeader(m *pmapi.Message) textproto.MIMEHeader {
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Type", "multipart/related; boundary="+GetRelatedBoundary(m))
|
||||
return h
|
||||
}
|
||||
|
||||
func GetAttachmentHeader(att *pmapi.Attachment) textproto.MIMEHeader {
|
||||
mediaType := att.MIMEType
|
||||
if mediaType == "application/pgp-encrypted" {
|
||||
mediaType = "application/octet-stream"
|
||||
}
|
||||
|
||||
encodedName := pmmime.EncodeHeader(att.Name)
|
||||
disposition := "attachment" //nolint[goconst]
|
||||
if strings.Contains(att.Header.Get("Content-Disposition"), "inline") {
|
||||
disposition = "inline"
|
||||
}
|
||||
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Type", mime.FormatMediaType(mediaType, map[string]string{"name": encodedName}))
|
||||
h.Set("Content-Transfer-Encoding", "base64")
|
||||
h.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": encodedName}))
|
||||
|
||||
// Forward some original header lines.
|
||||
forward := []string{"Content-Id", "Content-Description", "Content-Location"}
|
||||
for _, k := range forward {
|
||||
v := att.Header.Get(k)
|
||||
if v != "" {
|
||||
h.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// ========= Header parsing and sanitizing functions =========
|
||||
|
||||
func parseHeader(h mail.Header) (m *pmapi.Message, err error) { //nolint[unparam]
|
||||
m = pmapi.NewMessage()
|
||||
|
||||
if subject, err := pmmime.DecodeHeader(h.Get("Subject")); err == nil {
|
||||
m.Subject = subject
|
||||
}
|
||||
if addrs, err := sanitizeAddressList(h, "From"); err == nil && len(addrs) > 0 {
|
||||
m.Sender = addrs[0]
|
||||
}
|
||||
if addrs, err := sanitizeAddressList(h, "Reply-To"); err == nil && len(addrs) > 0 {
|
||||
m.ReplyTos = addrs
|
||||
}
|
||||
if addrs, err := sanitizeAddressList(h, "To"); err == nil {
|
||||
m.ToList = addrs
|
||||
}
|
||||
if addrs, err := sanitizeAddressList(h, "Cc"); err == nil {
|
||||
m.CCList = addrs
|
||||
}
|
||||
if addrs, err := sanitizeAddressList(h, "Bcc"); err == nil {
|
||||
m.BCCList = addrs
|
||||
}
|
||||
m.Time = 0
|
||||
if t, err := h.Date(); err == nil && !t.IsZero() {
|
||||
m.Time = t.Unix()
|
||||
}
|
||||
|
||||
m.Header = h
|
||||
return
|
||||
}
|
||||
|
||||
func sanitizeAddressList(h mail.Header, field string) (addrs []*mail.Address, err error) {
|
||||
raw := h.Get(field)
|
||||
if raw == "" {
|
||||
err = mail.ErrHeaderNotPresent
|
||||
return
|
||||
}
|
||||
var decoded string
|
||||
decoded, err = pmmime.DecodeHeader(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addrs, err = mail.ParseAddressList(parseAddressComment(decoded))
|
||||
if err == nil {
|
||||
if addrs == nil {
|
||||
addrs = []*mail.Address{}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Probably missing encoding error -- try to at least parse addresses in brackets.
|
||||
addrStr := h.Get(field)
|
||||
first := strings.Index(addrStr, "<")
|
||||
last := strings.LastIndex(addrStr, ">")
|
||||
if first < 0 || last < 0 || first >= last {
|
||||
return
|
||||
}
|
||||
var addrList []string
|
||||
open := first
|
||||
for open < last && 0 <= open {
|
||||
addrStr = addrStr[open:]
|
||||
close := strings.Index(addrStr, ">")
|
||||
addrList = append(addrList, addrStr[:close+1])
|
||||
addrStr = addrStr[close:]
|
||||
open = strings.Index(addrStr, "<")
|
||||
last = strings.LastIndex(addrStr, ">")
|
||||
}
|
||||
addrStr = strings.Join(addrList, ", ")
|
||||
//
|
||||
return mail.ParseAddressList(addrStr)
|
||||
}
|
||||
71
pkg/message/html.go
Normal file
71
pkg/message/html.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
escape "html"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/cascadia"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func plaintextToHTML(text string) (output string) {
|
||||
text = escape.EscapeString(text)
|
||||
text = strings.Replace(text, "\n\r", "<br>", -1)
|
||||
text = strings.Replace(text, "\r\n", "<br>", -1)
|
||||
text = strings.Replace(text, "\n", "<br>", -1)
|
||||
text = strings.Replace(text, "\r", "<br>", -1)
|
||||
|
||||
return "<div>" + text + "</div>"
|
||||
}
|
||||
|
||||
func stripHTML(input string) (stripped string, err error) {
|
||||
reader := strings.NewReader(input)
|
||||
doc, _ := html.Parse(reader)
|
||||
body := cascadia.MustCompile("body").MatchFirst(doc)
|
||||
var buf1 bytes.Buffer
|
||||
if err = html.Render(&buf1, body); err != nil {
|
||||
stripped = input
|
||||
return
|
||||
}
|
||||
stripped = buf1.String()
|
||||
// Handle double body tags edge case.
|
||||
if strings.Index(stripped, "<body") == 0 {
|
||||
startIndex := strings.Index(stripped, ">")
|
||||
if startIndex < 5 {
|
||||
return
|
||||
}
|
||||
stripped = stripped[startIndex+1:]
|
||||
// Closing body tag is optional.
|
||||
closingIndex := strings.Index(stripped, "</body>")
|
||||
if closingIndex > -1 {
|
||||
stripped = stripped[:closingIndex]
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func addOuterHTMLTags(input string) (output string) {
|
||||
return "<html><head></head><body>" + input + "</body></html>"
|
||||
}
|
||||
|
||||
func makeEmbeddedImageHTML(cid, name string) (output string) {
|
||||
return "<img class=\"proton-embedded\" alt=\"" + name + "\" src=\"cid:" + cid + "\">"
|
||||
}
|
||||
188
pkg/message/message.go
Normal file
188
pkg/message/message.go
Normal file
@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/jhillyerd/enmime"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const textPlain = "text/plain"
|
||||
|
||||
func GetBoundary(m *pmapi.Message) string {
|
||||
// The boundary needs to be deterministic because messages are not supposed to
|
||||
// change.
|
||||
return fmt.Sprintf("%x", sha512.Sum512_256([]byte(m.ID)))
|
||||
}
|
||||
|
||||
func GetRelatedBoundary(m *pmapi.Message) string {
|
||||
// The boundary needs to be deterministic because messages are not supposed to
|
||||
// change.
|
||||
return fmt.Sprintf("%x", sha512.Sum512_256([]byte(m.ID+m.ID)))
|
||||
}
|
||||
|
||||
func GetBodyStructure(m *pmapi.Message) (bs *imap.BodyStructure) { //nolint[funlen]
|
||||
bs = &imap.BodyStructure{
|
||||
MimeType: "multipart",
|
||||
MimeSubType: "mixed",
|
||||
Params: map[string]string{"boundary": GetBoundary(m)},
|
||||
}
|
||||
var inlineParts []*imap.BodyStructure
|
||||
var attParts []*imap.BodyStructure
|
||||
|
||||
for _, att := range m.Attachments {
|
||||
typeParts := strings.SplitN(att.MIMEType, "/", 2)
|
||||
if len(typeParts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if typeParts[0] == "application" && typeParts[1] == "pgp-encrypted" {
|
||||
typeParts[1] = "octet-stream"
|
||||
}
|
||||
|
||||
part := &imap.BodyStructure{
|
||||
MimeType: typeParts[0],
|
||||
MimeSubType: typeParts[1],
|
||||
Params: map[string]string{"name": att.Name},
|
||||
Encoding: "base64",
|
||||
}
|
||||
|
||||
if strings.Contains(att.Header.Get("Content-Disposition"), "inline") {
|
||||
part.Disposition = "inline"
|
||||
inlineParts = append(inlineParts, part)
|
||||
} else {
|
||||
part.Disposition = "attachment"
|
||||
attParts = append(attParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(inlineParts) > 0 {
|
||||
// Set to multipart-related for inline attachments.
|
||||
relatedPart := &imap.BodyStructure{
|
||||
MimeType: "multipart",
|
||||
MimeSubType: "related",
|
||||
Params: map[string]string{"boundary": GetRelatedBoundary(m)},
|
||||
}
|
||||
|
||||
subType := "html"
|
||||
|
||||
if m.MIMEType == textPlain {
|
||||
subType = "plain"
|
||||
}
|
||||
|
||||
relatedPart.Parts = append(relatedPart.Parts, &imap.BodyStructure{
|
||||
MimeType: "text",
|
||||
MimeSubType: subType,
|
||||
Params: map[string]string{"charset": "utf-8"},
|
||||
Encoding: "quoted-printable",
|
||||
Disposition: "inline",
|
||||
})
|
||||
|
||||
bs.Parts = append(bs.Parts, relatedPart)
|
||||
} else {
|
||||
subType := "html"
|
||||
|
||||
if m.MIMEType == textPlain {
|
||||
subType = "plain"
|
||||
}
|
||||
|
||||
bs.Parts = append(bs.Parts, &imap.BodyStructure{
|
||||
MimeType: "text",
|
||||
MimeSubType: subType,
|
||||
Params: map[string]string{"charset": "utf-8"},
|
||||
Encoding: "quoted-printable",
|
||||
Disposition: "inline",
|
||||
})
|
||||
}
|
||||
|
||||
bs.Parts = append(bs.Parts, attParts...)
|
||||
|
||||
return bs
|
||||
}
|
||||
|
||||
func SeparateInlineAttachments(m *pmapi.Message) (atts, inlines []*pmapi.Attachment) {
|
||||
for _, att := range m.Attachments {
|
||||
if strings.Contains(att.Header.Get("Content-Disposition"), "inline") {
|
||||
inlines = append(inlines, att)
|
||||
} else {
|
||||
atts = append(atts, att)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetMIMEBodyStructure(m *pmapi.Message, parsedMsg *enmime.Envelope) (bs *imap.BodyStructure, err error) {
|
||||
// We recursively look through the MIME structure.
|
||||
root := parsedMsg.Root
|
||||
if root == nil {
|
||||
return GetBodyStructure(m), nil
|
||||
}
|
||||
|
||||
mediaType, params, err := pmmime.ParseMediaType(root.ContentType)
|
||||
if err != nil {
|
||||
log.Warnf("Cannot parse Content-Type '%v': %v", root.ContentType, err)
|
||||
err = nil
|
||||
mediaType = textPlain
|
||||
}
|
||||
|
||||
typeParts := strings.SplitN(mediaType, "/", 2)
|
||||
|
||||
bs = &imap.BodyStructure{
|
||||
MimeType: typeParts[0],
|
||||
Params: params,
|
||||
}
|
||||
|
||||
if len(typeParts) > 1 {
|
||||
bs.MimeSubType = typeParts[1]
|
||||
}
|
||||
|
||||
bs.Parts = getChildrenParts(root)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getChildrenParts(root *enmime.Part) (parts []*imap.BodyStructure) {
|
||||
for child := root.FirstChild; child != nil; child = child.NextSibling {
|
||||
mediaType, params, err := pmmime.ParseMediaType(child.ContentType)
|
||||
if err != nil {
|
||||
log.Warnf("Cannot parse Content-Type '%v': %v", child.ContentType, err)
|
||||
mediaType = textPlain
|
||||
}
|
||||
typeParts := strings.SplitN(mediaType, "/", 2)
|
||||
childrenParts := getChildrenParts(child)
|
||||
part := &imap.BodyStructure{
|
||||
MimeType: typeParts[0],
|
||||
Params: params,
|
||||
Encoding: child.Charset,
|
||||
Disposition: child.Disposition,
|
||||
Parts: childrenParts,
|
||||
}
|
||||
if len(typeParts) > 1 {
|
||||
part.MimeSubType = typeParts[1]
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return
|
||||
}
|
||||
468
pkg/message/parser.go
Normal file
468
pkg/message/parser.go
Normal file
@ -0,0 +1,468 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/jaytaylor/html2text"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func parseAttachment(filename string, mediaType string, h textproto.MIMEHeader) (att *pmapi.Attachment) {
|
||||
if decoded, err := pmmime.DecodeHeader(filename); err == nil {
|
||||
filename = decoded
|
||||
}
|
||||
if filename == "" {
|
||||
ext, err := mime.ExtensionsByType(mediaType)
|
||||
if err == nil && len(ext) > 0 {
|
||||
filename = "attachment" + ext[0]
|
||||
}
|
||||
}
|
||||
|
||||
att = &pmapi.Attachment{
|
||||
Name: filename,
|
||||
MIMEType: mediaType,
|
||||
Header: h,
|
||||
}
|
||||
|
||||
headerContentID := strings.Trim(h.Get("Content-Id"), " <>")
|
||||
|
||||
if headerContentID != "" {
|
||||
att.ContentID = headerContentID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var reEmailComment = regexp.MustCompile("[(][^)]*[)]") //nolint[gochecknoglobals]
|
||||
|
||||
// parseAddressComment removes the comments completely even though they should be allowed
|
||||
// http://tools.wordtothewise.com/rfc/822
|
||||
// NOTE: This should be supported in go>1.10 but it seems it's not ¯\_(ツ)_/¯
|
||||
func parseAddressComment(raw string) string {
|
||||
return reEmailComment.ReplaceAllString(raw, "")
|
||||
}
|
||||
|
||||
// Some clients incorrectly format messages with embedded attachments to have a format like
|
||||
// I. text/plain II. attachment III. text/plain
|
||||
// which we need to convert to a single HTML part with an embedded attachment.
|
||||
func combineParts(m *pmapi.Message, parts []io.Reader, headers []textproto.MIMEHeader, convertPlainToHTML bool, atts *[]io.Reader) (isHTML bool, err error) { //nolint[funlen]
|
||||
isHTML = true
|
||||
foundText := false
|
||||
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
h := headers[i]
|
||||
|
||||
disp, dispParams, _ := pmmime.ParseMediaType(h.Get("Content-Disposition"))
|
||||
|
||||
d := pmmime.DecodeContentEncoding(part, h.Get("Content-Transfer-Encoding"))
|
||||
if d == nil {
|
||||
log.Warnf("Unsupported Content-Transfer-Encoding '%v'", h.Get("Content-Transfer-Encoding"))
|
||||
d = part
|
||||
}
|
||||
|
||||
contentType := h.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
mediaType, params, _ := pmmime.ParseMediaType(contentType)
|
||||
|
||||
if strings.HasPrefix(mediaType, "text/") && mediaType != "text/calendar" && disp != "attachment" {
|
||||
// This is text.
|
||||
var b []byte
|
||||
if b, err = ioutil.ReadAll(d); err != nil {
|
||||
continue
|
||||
}
|
||||
b, err = pmmime.DecodeCharset(b, params)
|
||||
if err != nil {
|
||||
log.Warn("Decode charset error: ", err)
|
||||
return false, err
|
||||
}
|
||||
contents := string(b)
|
||||
if strings.Contains(mediaType, "text/plain") && len(contents) > 0 {
|
||||
if !convertPlainToHTML {
|
||||
isHTML = false
|
||||
} else {
|
||||
contents = plaintextToHTML(contents)
|
||||
}
|
||||
} else if strings.Contains(mediaType, "text/html") && len(contents) > 0 {
|
||||
contents, err = stripHTML(contents)
|
||||
if err != nil {
|
||||
return isHTML, err
|
||||
}
|
||||
}
|
||||
m.Body = contents + m.Body
|
||||
foundText = true
|
||||
} else {
|
||||
// This is an attachment.
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
// Using "name" in Content-Type is discouraged.
|
||||
filename = params["name"]
|
||||
}
|
||||
if filename == "" && mediaType == "text/calendar" {
|
||||
filename = "event.ics"
|
||||
}
|
||||
|
||||
att := parseAttachment(filename, mediaType, h)
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
if _, err = io.Copy(b, d); err != nil {
|
||||
continue
|
||||
}
|
||||
if foundText && att.ContentID == "" && strings.Contains(mediaType, "image") {
|
||||
// Treat this as an inline attachment even though it is not marked as one.
|
||||
hasher := sha256.New()
|
||||
_, _ = hasher.Write([]byte(att.Name + strconv.Itoa(b.Len())))
|
||||
bytes := hasher.Sum(nil)
|
||||
cid := hex.EncodeToString(bytes) + "@protonmail.com"
|
||||
|
||||
att.ContentID = cid
|
||||
embeddedHTML := makeEmbeddedImageHTML(cid, att.Name)
|
||||
m.Body = embeddedHTML + m.Body
|
||||
}
|
||||
|
||||
m.Attachments = append(m.Attachments, att)
|
||||
*atts = append(*atts, b)
|
||||
}
|
||||
}
|
||||
if isHTML {
|
||||
m.Body = addOuterHTMLTags(m.Body)
|
||||
}
|
||||
return isHTML, nil
|
||||
}
|
||||
|
||||
func checkHeaders(headers []textproto.MIMEHeader) bool {
|
||||
foundAttachment := false
|
||||
|
||||
for i := 0; i < len(headers); i++ {
|
||||
h := headers[i]
|
||||
|
||||
mediaType, _, _ := pmmime.ParseMediaType(h.Get("Content-Type"))
|
||||
|
||||
if !strings.HasPrefix(mediaType, "text/") {
|
||||
foundAttachment = true
|
||||
} else if foundAttachment {
|
||||
// This means that there is a text part after the first attachment,
|
||||
// so we will have to convert the body from plain->HTML.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================== 7bit Filter ==========================
|
||||
// For every MIME part in the tree that has "8bit" or "binary" content
|
||||
// transfer encoding: transcode it to "quoted-printable".
|
||||
|
||||
type SevenBitFilter struct {
|
||||
target pmmime.VisitAcceptor
|
||||
}
|
||||
|
||||
func NewSevenBitFilter(targetAccepter pmmime.VisitAcceptor) *SevenBitFilter {
|
||||
return &SevenBitFilter{
|
||||
target: targetAccepter,
|
||||
}
|
||||
}
|
||||
|
||||
func decodePart(partReader io.Reader, header textproto.MIMEHeader) (decodedPart io.Reader) {
|
||||
decodedPart = pmmime.DecodeContentEncoding(partReader, header.Get("Content-Transfer-Encoding"))
|
||||
if decodedPart == nil {
|
||||
log.Warnf("Unsupported Content-Transfer-Encoding '%v'", header.Get("Content-Transfer-Encoding"))
|
||||
decodedPart = partReader
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (sd SevenBitFilter) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) error {
|
||||
cte := strings.ToLower(header.Get("Content-Transfer-Encoding"))
|
||||
if isFirst && pmmime.IsLeaf(header) && cte != "quoted-printable" && cte != "base64" && cte != "7bit" {
|
||||
decodedPart := decodePart(partReader, header)
|
||||
|
||||
filteredHeader := textproto.MIMEHeader{}
|
||||
for k, v := range header {
|
||||
filteredHeader[k] = v
|
||||
}
|
||||
filteredHeader.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
//filteredHeader.Set("Content-Transfer-Encoding", "base64")
|
||||
|
||||
filteredBuffer := &bytes.Buffer{}
|
||||
decodedSlice, _ := ioutil.ReadAll(decodedPart)
|
||||
w := quotedprintable.NewWriter(filteredBuffer)
|
||||
//w := base64.NewEncoder(base64.StdEncoding, filteredBuffer)
|
||||
if _, err := w.Write(decodedSlice); err != nil {
|
||||
log.Errorf("cannot write quotedprintable from %q: %v", cte, err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
log.Errorf("cannot close quotedprintable from %q: %v", cte, err)
|
||||
}
|
||||
|
||||
_ = sd.target.Accept(filteredBuffer, filteredHeader, hasPlainSibling, true, isLast)
|
||||
} else {
|
||||
_ = sd.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// =================== HTML Only convertor ==================================
|
||||
// In any part of MIME tree structure, replace standalone text/html with
|
||||
// multipart/alternative containing both text/html and text/plain.
|
||||
|
||||
type HTMLOnlyConvertor struct {
|
||||
target pmmime.VisitAcceptor
|
||||
}
|
||||
|
||||
func NewHTMLOnlyConvertor(targetAccepter pmmime.VisitAcceptor) *HTMLOnlyConvertor {
|
||||
return &HTMLOnlyConvertor{
|
||||
target: targetAccepter,
|
||||
}
|
||||
}
|
||||
|
||||
func randomBoundary() string {
|
||||
var buf [30]byte
|
||||
_, err := io.ReadFull(rand.Reader, buf[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", buf[:])
|
||||
}
|
||||
|
||||
func (hoc HTMLOnlyConvertor) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error {
|
||||
mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type"))
|
||||
if isFirst && err == nil && mediaType == "text/html" && !hasPlainSiblings {
|
||||
multiPartHeaders := make(textproto.MIMEHeader)
|
||||
for k, v := range header {
|
||||
multiPartHeaders[k] = v
|
||||
}
|
||||
boundary := randomBoundary()
|
||||
multiPartHeaders.Set("Content-Type", "multipart/alternative; boundary=\""+boundary+"\"")
|
||||
childCte := header.Get("Content-Transfer-Encoding")
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, false, true, false)
|
||||
|
||||
partData, _ := ioutil.ReadAll(partReader)
|
||||
|
||||
htmlChildHeaders := make(textproto.MIMEHeader)
|
||||
htmlChildHeaders.Set("Content-Transfer-Encoding", childCte)
|
||||
htmlChildHeaders.Set("Content-Type", "text/html")
|
||||
htmlReader := bytes.NewReader(partData)
|
||||
_ = hoc.target.Accept(htmlReader, htmlChildHeaders, false, true, false)
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false)
|
||||
|
||||
plainChildHeaders := make(textproto.MIMEHeader)
|
||||
plainChildHeaders.Set("Content-Transfer-Encoding", childCte)
|
||||
plainChildHeaders.Set("Content-Type", "text/plain")
|
||||
unHtmlized, err := html2text.FromReader(bytes.NewReader(partData))
|
||||
if err != nil {
|
||||
unHtmlized = string(partData)
|
||||
}
|
||||
plainReader := strings.NewReader(unHtmlized)
|
||||
_ = hoc.target.Accept(plainReader, plainChildHeaders, false, true, true)
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true)
|
||||
} else {
|
||||
_ = hoc.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======= Public Key Attacher ========
|
||||
|
||||
type PublicKeyAttacher struct {
|
||||
target pmmime.VisitAcceptor
|
||||
attachedPublicKey string
|
||||
attachedPublicKeyName string
|
||||
appendToMultipart bool
|
||||
depth int
|
||||
}
|
||||
|
||||
func NewPublicKeyAttacher(targetAccepter pmmime.VisitAcceptor, attachedPublicKey, attachedPublicKeyName string) *PublicKeyAttacher {
|
||||
return &PublicKeyAttacher{
|
||||
target: targetAccepter,
|
||||
attachedPublicKey: attachedPublicKey,
|
||||
attachedPublicKeyName: attachedPublicKeyName,
|
||||
appendToMultipart: false,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func split(input string, sliceLength int) string {
|
||||
processed := input
|
||||
result := ""
|
||||
for len(processed) > 0 {
|
||||
cutPoint := min(sliceLength, len(processed))
|
||||
part := processed[0:cutPoint]
|
||||
result = result + part + "\n"
|
||||
processed = processed[cutPoint:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func createKeyAttachment(publicKey, publicKeyName string) (headers textproto.MIMEHeader, contents io.Reader) {
|
||||
attachmentHeaders := make(textproto.MIMEHeader)
|
||||
attachmentHeaders.Set("Content-Type", "application/pgp-key; name=\""+publicKeyName+"\"")
|
||||
attachmentHeaders.Set("Content-Transfer-Encoding", "base64")
|
||||
attachmentHeaders.Set("Content-Disposition", "attachment; filename=\""+publicKeyName+".asc.pgp\"")
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
w := base64.NewEncoder(base64.StdEncoding, buffer)
|
||||
_, _ = w.Write([]byte(publicKey))
|
||||
_ = w.Close()
|
||||
|
||||
return attachmentHeaders, strings.NewReader(split(buffer.String(), 73))
|
||||
}
|
||||
|
||||
func (pka *PublicKeyAttacher) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error {
|
||||
if isFirst && !pmmime.IsLeaf(header) {
|
||||
pka.depth++
|
||||
}
|
||||
if isLast && !pmmime.IsLeaf(header) {
|
||||
defer func() {
|
||||
pka.depth--
|
||||
}()
|
||||
}
|
||||
isRoot := (header.Get("From") != "")
|
||||
mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type"))
|
||||
if isRoot && isFirst && err == nil && pka.attachedPublicKey != "" { //nolint[gocritic]
|
||||
if strings.HasPrefix(mediaType, "multipart/mixed") {
|
||||
pka.appendToMultipart = true
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
} else {
|
||||
// Create two siblings with attachment in the case toplevel is not multipart/mixed.
|
||||
multiPartHeaders := make(textproto.MIMEHeader)
|
||||
for k, v := range header {
|
||||
multiPartHeaders[k] = v
|
||||
}
|
||||
boundary := randomBoundary()
|
||||
multiPartHeaders.Set("Content-Type", "multipart/mixed; boundary=\""+boundary+"\"")
|
||||
multiPartHeaders.Del("Content-Transfer-Encoding")
|
||||
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, false, true, false)
|
||||
|
||||
originalHeader := make(textproto.MIMEHeader)
|
||||
originalHeader.Set("Content-Type", header.Get("Content-Type"))
|
||||
if header.Get("Content-Transfer-Encoding") != "" {
|
||||
originalHeader.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding"))
|
||||
}
|
||||
|
||||
_ = pka.target.Accept(partReader, originalHeader, false, true, false)
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false)
|
||||
|
||||
attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName)
|
||||
|
||||
_ = pka.target.Accept(attachmentReader, attachmentHeaders, false, true, true)
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true)
|
||||
}
|
||||
} else if isLast && pka.depth == 1 && pka.attachedPublicKey != "" {
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, false)
|
||||
attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName)
|
||||
_ = pka.target.Accept(attachmentReader, attachmentHeaders, hasPlainSiblings, true, true)
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, true)
|
||||
} else {
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======= Parser ==========
|
||||
|
||||
func Parse(r io.Reader, attachedPublicKey, attachedPublicKeyName string) (m *pmapi.Message, mimeBody string, plainContents string, atts []io.Reader, err error) {
|
||||
secondReader := new(bytes.Buffer)
|
||||
_, _ = secondReader.ReadFrom(r)
|
||||
|
||||
mimeBody = secondReader.String()
|
||||
|
||||
mm, err := mail.ReadMessage(secondReader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if m, err = parseHeader(mm.Header); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader(m.Header)
|
||||
mmBodyData, err := ioutil.ReadAll(mm.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
printAccepter := pmmime.NewMIMEPrinter()
|
||||
|
||||
publicKeyAttacher := NewPublicKeyAttacher(printAccepter, attachedPublicKey, attachedPublicKeyName)
|
||||
sevenBitFilter := NewSevenBitFilter(publicKeyAttacher)
|
||||
|
||||
plainTextCollector := pmmime.NewPlainTextCollector(sevenBitFilter)
|
||||
htmlOnlyConvertor := NewHTMLOnlyConvertor(plainTextCollector)
|
||||
|
||||
visitor := pmmime.NewMimeVisitor(htmlOnlyConvertor)
|
||||
err = pmmime.VisitAll(bytes.NewReader(mmBodyData), h, visitor)
|
||||
/*
|
||||
err = visitor.VisitAll(h, bytes.NewReader(mmBodyData))
|
||||
*/
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimeBody = printAccepter.String()
|
||||
plainContents = plainTextCollector.GetPlainText()
|
||||
|
||||
parts, headers, err := pmmime.GetAllChildParts(bytes.NewReader(mmBodyData), h)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
convertPlainToHTML := checkHeaders(headers)
|
||||
isHTML, err := combineParts(m, parts, headers, convertPlainToHTML, &atts)
|
||||
|
||||
if isHTML {
|
||||
m.MIMEType = "text/html"
|
||||
} else {
|
||||
m.MIMEType = "text/plain"
|
||||
}
|
||||
|
||||
return m, mimeBody, plainContents, atts, err
|
||||
}
|
||||
107
pkg/message/parser_test.go
Normal file
107
pkg/message/parser_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRFC822AddressFormat(t *testing.T) { //nolint[funlen]
|
||||
tests := []struct {
|
||||
address string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
" normal name <username@server.com>",
|
||||
[]string{
|
||||
"\"normal name\" <username@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" \"comma, name\" <username@server.com>",
|
||||
[]string{
|
||||
"\"comma, name\" <username@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" name <username@server.com> (ignore comment)",
|
||||
[]string{
|
||||
"\"name\" <username@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" name (ignore comment) <username@server.com>, (Comment as name) username2@server.com",
|
||||
[]string{
|
||||
"\"name\" <username@server.com>",
|
||||
"<username2@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" normal name <username@server.com>, (comment)All.(around)address@(the)server.com",
|
||||
[]string{
|
||||
"\"normal name\" <username@server.com>",
|
||||
"<All.address@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" normal name <username@server.com>, All.(\"comma, in comment\")address@(the)server.com",
|
||||
[]string{
|
||||
"\"normal name\" <username@server.com>",
|
||||
"<All.address@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" \"normal name\" <username@server.com>, \"comma, name\" <address@server.com>",
|
||||
[]string{
|
||||
"\"normal name\" <username@server.com>",
|
||||
"\"comma, name\" <address@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" \"comma, one\" <username@server.com>, \"comma, two\" <address@server.com>",
|
||||
[]string{
|
||||
"\"comma, one\" <username@server.com>",
|
||||
"\"comma, two\" <address@server.com>",
|
||||
},
|
||||
},
|
||||
{
|
||||
" \"comma, name\" <username@server.com>, another, name <address@server.com>",
|
||||
[]string{
|
||||
"\"comma, name\" <username@server.com>",
|
||||
"\"another, name\" <address@server.com>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, data := range tests {
|
||||
uncommented := parseAddressComment(data.address)
|
||||
result, err := mail.ParseAddressList(uncommented)
|
||||
if err != nil {
|
||||
t.Errorf("Can not parse '%s' created from '%s': %v", uncommented, data.address, err)
|
||||
}
|
||||
if len(result) != len(data.expected) {
|
||||
t.Errorf("Wrong parsing of '%s' created from '%s': expected '%s' but have '%+v'", uncommented, data.address, data.expected, result)
|
||||
}
|
||||
for i, result := range result {
|
||||
if data.expected[i] != result.String() {
|
||||
t.Errorf("Wrong parsing\nof %q\ncreated from %q:\nexpected %q\nbut have %q", uncommented, data.address, data.expected, result.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
413
pkg/message/section.go
Normal file
413
pkg/message/section.go
Normal file
@ -0,0 +1,413 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
type sectionInfo struct {
|
||||
header textproto.MIMEHeader
|
||||
start, bsize, size, lines int
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
// Count and read.
|
||||
func (si *sectionInfo) Read(p []byte) (n int, err error) {
|
||||
n, err = si.reader.Read(p)
|
||||
si.size += n
|
||||
si.lines += bytes.Count(p, []byte("\n"))
|
||||
return
|
||||
}
|
||||
|
||||
type boundaryReader struct {
|
||||
reader *bufio.Reader
|
||||
|
||||
closed, first bool
|
||||
skipped int
|
||||
|
||||
nl []byte // "\r\n" or "\n" (set after seeing first boundary line)
|
||||
nlDashBoundary []byte // nl + "--boundary"
|
||||
dashBoundaryDash []byte // "--boundary--"
|
||||
dashBoundary []byte // "--boundary"
|
||||
}
|
||||
|
||||
func newBoundaryReader(r *bufio.Reader, boundary string) (br *boundaryReader, err error) {
|
||||
b := []byte("\r\n--" + boundary + "--")
|
||||
br = &boundaryReader{
|
||||
reader: r,
|
||||
closed: false,
|
||||
first: true,
|
||||
nl: b[:2],
|
||||
nlDashBoundary: b[:len(b)-2],
|
||||
dashBoundaryDash: b[2:],
|
||||
dashBoundary: b[2 : len(b)-2],
|
||||
}
|
||||
err = br.WriteNextPartTo(nil)
|
||||
return
|
||||
}
|
||||
|
||||
func skipLWSPChar(b []byte) []byte {
|
||||
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') {
|
||||
b = b[1:]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (br *boundaryReader) isFinalBoundary(line []byte) bool {
|
||||
if !bytes.HasPrefix(line, br.dashBoundaryDash) {
|
||||
return false
|
||||
}
|
||||
rest := line[len(br.dashBoundaryDash):]
|
||||
rest = skipLWSPChar(rest)
|
||||
return len(rest) == 0 || bytes.Equal(rest, br.nl)
|
||||
}
|
||||
|
||||
func (br *boundaryReader) isBoundaryDelimiterLine(line []byte) (ret bool) {
|
||||
if !bytes.HasPrefix(line, br.dashBoundary) {
|
||||
return false
|
||||
}
|
||||
rest := line[len(br.dashBoundary):]
|
||||
rest = skipLWSPChar(rest)
|
||||
|
||||
if br.first && len(rest) == 1 && rest[0] == '\n' {
|
||||
br.nl = br.nl[1:]
|
||||
br.nlDashBoundary = br.nlDashBoundary[1:]
|
||||
}
|
||||
return bytes.Equal(rest, br.nl)
|
||||
}
|
||||
|
||||
func (br *boundaryReader) WriteNextPartTo(part io.Writer) (err error) {
|
||||
if br.closed {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
var line, slice []byte
|
||||
br.skipped = 0
|
||||
|
||||
for {
|
||||
slice, err = br.reader.ReadSlice('\n')
|
||||
line = append(line, slice...)
|
||||
if err == bufio.ErrBufferFull {
|
||||
continue
|
||||
}
|
||||
|
||||
br.skipped += len(line)
|
||||
|
||||
if err == io.EOF && br.isFinalBoundary(line) {
|
||||
err = nil
|
||||
br.closed = true
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if br.isBoundaryDelimiterLine(line) {
|
||||
br.first = false
|
||||
return
|
||||
}
|
||||
|
||||
if br.isFinalBoundary(line) {
|
||||
br.closed = true
|
||||
return
|
||||
}
|
||||
|
||||
if part != nil {
|
||||
if _, err = part.Write(line); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
line = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
type BodyStructure map[string]*sectionInfo
|
||||
|
||||
func NewBodyStructure(reader io.Reader) (structure *BodyStructure, err error) {
|
||||
structure = &BodyStructure{}
|
||||
err = structure.Parse(reader)
|
||||
return
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) Parse(r io.Reader) error {
|
||||
return bs.parseAllChildSections(r, []int{}, 0)
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) parseAllChildSections(r io.Reader, currentPath []int, start int) (err error) { //nolint[funlen]
|
||||
info := §ionInfo{
|
||||
start: start,
|
||||
size: 0,
|
||||
bsize: 0,
|
||||
lines: 0,
|
||||
reader: r,
|
||||
}
|
||||
|
||||
bufInfo := bufio.NewReader(info)
|
||||
tp := textproto.NewReader(bufInfo)
|
||||
|
||||
if info.header, err = tp.ReadMIMEHeader(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bodyInfo := §ionInfo{reader: tp.R}
|
||||
bodyReader := bufio.NewReader(bodyInfo)
|
||||
|
||||
mediaType, params, _ := pmmime.ParseMediaType(info.header.Get("Content-Type"))
|
||||
|
||||
// If multipart, call getAllParts, else read to count lines.
|
||||
if (strings.HasPrefix(mediaType, "multipart/") || mediaType == "message/rfc822") && params["boundary"] != "" {
|
||||
newPath := append(currentPath, 1)
|
||||
|
||||
var br *boundaryReader
|
||||
br, err = newBoundaryReader(bodyReader, params["boundary"])
|
||||
// New reader seeks first boundary.
|
||||
if err != nil {
|
||||
// Return also EOF.
|
||||
return
|
||||
}
|
||||
|
||||
for err == nil {
|
||||
start += br.skipped
|
||||
part := &bytes.Buffer{}
|
||||
err = br.WriteNextPartTo(part)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
err = bs.parseAllChildSections(part, newPath, start)
|
||||
part.Reset()
|
||||
newPath[len(newPath)-1]++
|
||||
}
|
||||
br.reader = nil
|
||||
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Count length.
|
||||
_, _ = bodyReader.WriteTo(ioutil.Discard)
|
||||
}
|
||||
|
||||
// Clear all buffers.
|
||||
bodyReader = nil
|
||||
bodyInfo.reader = nil
|
||||
tp.R = nil
|
||||
tp = nil
|
||||
bufInfo = nil // nolint
|
||||
info.reader = nil
|
||||
|
||||
// Store boundaries.
|
||||
info.bsize = bodyInfo.size
|
||||
path := stringPathFromInts(currentPath)
|
||||
(*bs)[path] = info
|
||||
|
||||
// Fix start of subsections.
|
||||
newPath := append(currentPath, 1)
|
||||
shift := info.size - info.bsize
|
||||
subInfo, err := bs.getInfo(newPath)
|
||||
|
||||
// If it has subparts.
|
||||
for err == nil {
|
||||
subInfo.start += shift
|
||||
|
||||
// Level down.
|
||||
subInfo, err = bs.getInfo(append(newPath, 1))
|
||||
if err == nil {
|
||||
newPath = append(newPath, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
// Next.
|
||||
newPath[len(newPath)-1]++
|
||||
subInfo, err = bs.getInfo(newPath)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Level up.
|
||||
for {
|
||||
newPath = newPath[:len(newPath)-1]
|
||||
if len(newPath) > 0 {
|
||||
newPath[len(newPath)-1]++
|
||||
subInfo, err = bs.getInfo(newPath)
|
||||
if err != nil {
|
||||
err = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// The end.
|
||||
if len(newPath) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringPathFromInts(ints []int) (ret string) {
|
||||
for i, n := range ints {
|
||||
if i != 0 {
|
||||
ret += "."
|
||||
}
|
||||
ret += strconv.Itoa(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) getInfo(sectionPath []int) (sectionInfo *sectionInfo, err error) {
|
||||
path := stringPathFromInts(sectionPath)
|
||||
sectionInfo, ok := (*bs)[path]
|
||||
if !ok {
|
||||
err = errors.New("wrong section " + path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) GetSection(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = wholeMail.Seek(int64(info.start), io.SeekStart); err != nil {
|
||||
return
|
||||
}
|
||||
section = make([]byte, info.size)
|
||||
_, err = wholeMail.Read(section)
|
||||
return
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) GetSectionContent(wholeMail io.ReadSeeker, sectionPath []int) (section []byte, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = wholeMail.Seek(int64(info.start+info.size-info.bsize), io.SeekStart); err != nil {
|
||||
return
|
||||
}
|
||||
section = make([]byte, info.bsize)
|
||||
_, err = wholeMail.Read(section)
|
||||
return
|
||||
|
||||
/* This is slow:
|
||||
sectionBuf, err := bs.GetSection(wholeMail, sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tp := textproto.NewReader(bufio.NewReader(buf))
|
||||
if _, err = tp.ReadMIMEHeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sectionBuf = &bytes.Buffer{}
|
||||
_, err = io.Copy(sectionBuf, tp.R)
|
||||
return
|
||||
*/
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) GetSectionHeader(sectionPath []int) (header textproto.MIMEHeader, err error) {
|
||||
info, err := bs.getInfo(sectionPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
header = info.header
|
||||
return
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) Size() uint32 {
|
||||
info, err := bs.getInfo([]int{})
|
||||
if err != nil {
|
||||
return uint32(0)
|
||||
}
|
||||
return uint32(info.size)
|
||||
}
|
||||
|
||||
func (bs *BodyStructure) IMAPBodyStructure(currentPart []int) (imapBS *imap.BodyStructure, err error) {
|
||||
var info *sectionInfo
|
||||
if info, err = bs.getInfo(currentPart); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mediaType, params, _ := pmmime.ParseMediaType(info.header.Get("Content-Type"))
|
||||
|
||||
mediaTypeSep := strings.Split(mediaType, "/")
|
||||
|
||||
// If it is empty or missing it will not crash.
|
||||
mediaTypeSep = append(mediaTypeSep, "")
|
||||
|
||||
imapBS = &imap.BodyStructure{
|
||||
MimeType: mediaTypeSep[0],
|
||||
MimeSubType: mediaTypeSep[1],
|
||||
Params: params,
|
||||
Size: uint32(info.bsize),
|
||||
Lines: uint32(info.lines),
|
||||
}
|
||||
|
||||
if val := info.header.Get("Content-ID"); val != "" {
|
||||
imapBS.Id = val
|
||||
}
|
||||
|
||||
if val := info.header.Get("Content-Transfer-Encoding"); val != "" {
|
||||
imapBS.Encoding = val
|
||||
}
|
||||
|
||||
if val := info.header.Get("Content-Description"); val != "" {
|
||||
imapBS.Description = val
|
||||
}
|
||||
|
||||
if val := info.header.Get("Content-Disposition"); val != "" {
|
||||
imapBS.Disposition = val
|
||||
}
|
||||
|
||||
nextPart := append(currentPart, 1)
|
||||
for {
|
||||
if _, err := bs.getInfo(nextPart); err != nil {
|
||||
break
|
||||
}
|
||||
var subStruct *imap.BodyStructure
|
||||
subStruct, err = bs.IMAPBodyStructure(nextPart)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if imapBS.Parts == nil {
|
||||
imapBS.Parts = []*imap.BodyStructure{}
|
||||
}
|
||||
imapBS.Parts = append(imapBS.Parts, subStruct)
|
||||
nextPart[len(nextPart)-1]++
|
||||
}
|
||||
|
||||
return imapBS, nil
|
||||
}
|
||||
414
pkg/message/section_test.go
Normal file
414
pkg/message/section_test.go
Normal file
@ -0,0 +1,414 @@
|
||||
// Copyright (c) 2020 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 message
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func debug(msg string, v ...interface{}) {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
fmt.Printf("%s:%d: \033[2;33m"+msg+"\033[0;39m\n", append([]interface{}{filepath.Base(file), line}, v...)...)
|
||||
}
|
||||
|
||||
func TestParseBodyStructure(t *testing.T) {
|
||||
expectedStructure := map[string]string{
|
||||
"": "multipart/mixed; boundary=\"0000MAIN\"",
|
||||
"1": "text/plain",
|
||||
"2": "application/octet-stream",
|
||||
"3": "message/rfc822; boundary=\"0003MSG\"",
|
||||
"3.1": "text/plain",
|
||||
"3.2": "application/octet-stream",
|
||||
"4": "multipart/mixed; boundary=\"0004ATTACH\"",
|
||||
"4.1": "image/gif",
|
||||
"4.2": "message/rfc822; boundary=\"0042MSG\"",
|
||||
"4.2.1": "text/plain",
|
||||
"4.2.2": "multipart/alternative; boundary=\"0422ALTER\"",
|
||||
"4.2.2.1": "text/plain",
|
||||
"4.2.2.2": "text/html",
|
||||
}
|
||||
mailReader := strings.NewReader(sampleMail)
|
||||
bs, err := NewBodyStructure(mailReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
paths := []string{}
|
||||
for path := range *bs {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
debug("%10s: %-50s %5s %5s %5s %5s", "section", "type", "start", "size", "bsize", "lines")
|
||||
for _, path := range paths {
|
||||
sec := (*bs)[path]
|
||||
contentType := sec.header.Get("Content-Type")
|
||||
debug("%10s: %-50s %5d %5d %5d %5d", path, contentType, sec.start, sec.size, sec.bsize, sec.lines)
|
||||
require.Equal(t, expectedStructure[path], contentType)
|
||||
}
|
||||
|
||||
require.True(t, len(*bs) == len(expectedStructure), "Wrong number of sections expected %d but have %d", len(expectedStructure), len(*bs))
|
||||
}
|
||||
|
||||
func TestGetSection(t *testing.T) {
|
||||
structReader := strings.NewReader(sampleMail)
|
||||
bs, err := NewBodyStructure(structReader)
|
||||
require.NoError(t, err)
|
||||
// Whole section.
|
||||
for _, try := range testPaths {
|
||||
mailReader := strings.NewReader(sampleMail)
|
||||
info, err := bs.getInfo(try.path)
|
||||
require.NoError(t, err)
|
||||
section, err := bs.GetSection(mailReader, try.path)
|
||||
require.NoError(t, err)
|
||||
|
||||
debug("section %v: %d %d\n___\n%s\n‾‾‾\n", try.path, info.start, info.size, string(section))
|
||||
|
||||
require.True(t, string(section) == try.expectedSection, "not same as expected:\n___\n%s\n‾‾‾", try.expectedSection)
|
||||
}
|
||||
// Body content.
|
||||
for _, try := range testPaths {
|
||||
mailReader := strings.NewReader(sampleMail)
|
||||
info, err := bs.getInfo(try.path)
|
||||
require.NoError(t, err)
|
||||
section, err := bs.GetSectionContent(mailReader, try.path)
|
||||
require.NoError(t, err)
|
||||
|
||||
debug("content %v: %d %d\n___\n%s\n‾‾‾\n", try.path, info.start+info.size-info.bsize, info.bsize, string(section))
|
||||
|
||||
require.True(t, string(section) == try.expectedBody, "not same as expected:\n___\n%s\n‾‾‾", try.expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
/* Structure example:
|
||||
HEADER ([RFC-2822] header of the message)
|
||||
TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
|
||||
1 TEXT/PLAIN
|
||||
2 APPLICATION/OCTET-STREAM
|
||||
3 MESSAGE/RFC822
|
||||
3.HEADER ([RFC-2822] header of the message)
|
||||
3.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
|
||||
3.1 TEXT/PLAIN
|
||||
3.2 APPLICATION/OCTET-STREAM
|
||||
4 MULTIPART/MIXED
|
||||
4.1 IMAGE/GIF
|
||||
4.1.MIME ([MIME-IMB] header for the IMAGE/GIF)
|
||||
4.2 MESSAGE/RFC822
|
||||
4.2.HEADER ([RFC-2822] header of the message)
|
||||
4.2.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
|
||||
4.2.1 TEXT/PLAIN
|
||||
4.2.2 MULTIPART/ALTERNATIVE
|
||||
4.2.2.1 TEXT/PLAIN
|
||||
4.2.2.2 TEXT/RICHTEXT
|
||||
*/
|
||||
|
||||
var sampleMail = `Subject: Sample mail
|
||||
From: John Doe <jdoe@machine.example>
|
||||
To: Mary Smith <mary@example.net>
|
||||
Date: Fri, 21 Nov 1997 09:55:06 -0600
|
||||
Content-Type: multipart/mixed; boundary="0000MAIN"
|
||||
|
||||
main summary
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: text/plain
|
||||
|
||||
1. main message
|
||||
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: inline; filename="main_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
2/MainOctetStream
|
||||
|
||||
--0000MAIN
|
||||
Subject: Inside mail 3
|
||||
From: Mary Smith <mary@example.net>
|
||||
To: John Doe <jdoe@machine.example>
|
||||
Date: Fri, 20 Nov 1997 09:55:06 -0600
|
||||
Content-Type: message/rfc822; boundary="0003MSG"
|
||||
|
||||
3. message summary
|
||||
|
||||
--0003MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
3.1 message text
|
||||
|
||||
--0003MSG
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="msg_3_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
3/2/MessageOctestStream/==
|
||||
|
||||
--0003MSG--
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: multipart/mixed; boundary="0004ATTACH"
|
||||
|
||||
4 attach summary
|
||||
|
||||
--0004ATTACH
|
||||
Content-Type: image/gif
|
||||
Content-Disposition: attachment; filename="att4.1_gif.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
4/1/Gif=
|
||||
|
||||
--0004ATTACH
|
||||
Subject: Inside mail 4.2
|
||||
From: Mary Smith <mary@example.net>
|
||||
To: John Doe <jdoe@machine.example>
|
||||
Date: Fri, 10 Nov 1997 09:55:06 -0600
|
||||
Content-Type: message/rfc822; boundary="0042MSG"
|
||||
|
||||
4.2 message summary
|
||||
|
||||
--0042MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
4.2.1 message text
|
||||
|
||||
--0042MSG
|
||||
Content-Type: multipart/alternative; boundary="0422ALTER"
|
||||
|
||||
4.2.2 alternative summary
|
||||
|
||||
--0422ALTER
|
||||
Content-Type: text/plain
|
||||
|
||||
4.2.2.1 plain text
|
||||
|
||||
--0422ALTER
|
||||
Content-Type: text/html
|
||||
|
||||
<h1>4.2.2.2 html text</h1>
|
||||
|
||||
--0422ALTER--
|
||||
|
||||
--0042MSG--
|
||||
|
||||
--0004ATTACH--
|
||||
|
||||
--0000MAIN--
|
||||
|
||||
|
||||
`
|
||||
|
||||
var testPaths = []struct {
|
||||
path []int
|
||||
expectedSection, expectedBody string
|
||||
}{
|
||||
{[]int{},
|
||||
sampleMail,
|
||||
`main summary
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: text/plain
|
||||
|
||||
1. main message
|
||||
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: inline; filename="main_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
2/MainOctetStream
|
||||
|
||||
--0000MAIN
|
||||
Subject: Inside mail 3
|
||||
From: Mary Smith <mary@example.net>
|
||||
To: John Doe <jdoe@machine.example>
|
||||
Date: Fri, 20 Nov 1997 09:55:06 -0600
|
||||
Content-Type: message/rfc822; boundary="0003MSG"
|
||||
|
||||
3. message summary
|
||||
|
||||
--0003MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
3.1 message text
|
||||
|
||||
--0003MSG
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="msg_3_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
3/2/MessageOctestStream/==
|
||||
|
||||
--0003MSG--
|
||||
|
||||
--0000MAIN
|
||||
Content-Type: multipart/mixed; boundary="0004ATTACH"
|
||||
|
||||
4 attach summary
|
||||
|
||||
--0004ATTACH
|
||||
Content-Type: image/gif
|
||||
Content-Disposition: attachment; filename="att4.1_gif.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
4/1/Gif=
|
||||
|
||||
--0004ATTACH
|
||||
Subject: Inside mail 4.2
|
||||
From: Mary Smith <mary@example.net>
|
||||
To: John Doe <jdoe@machine.example>
|
||||
Date: Fri, 10 Nov 1997 09:55:06 -0600
|
||||
Content-Type: message/rfc822; boundary="0042MSG"
|
||||
|
||||
4.2 message summary
|
||||
|
||||
--0042MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
4.2.1 message text
|
||||
|
||||
--0042MSG
|
||||
Content-Type: multipart/alternative; boundary="0422ALTER"
|
||||
|
||||
4.2.2 alternative summary
|
||||
|
||||
--0422ALTER
|
||||
Content-Type: text/plain
|
||||
|
||||
4.2.2.1 plain text
|
||||
|
||||
--0422ALTER
|
||||
Content-Type: text/html
|
||||
|
||||
<h1>4.2.2.2 html text</h1>
|
||||
|
||||
--0422ALTER--
|
||||
|
||||
--0042MSG--
|
||||
|
||||
--0004ATTACH--
|
||||
|
||||
--0000MAIN--
|
||||
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
{[]int{1},
|
||||
`Content-Type: text/plain
|
||||
|
||||
1. main message
|
||||
|
||||
|
||||
`,
|
||||
`1. main message
|
||||
|
||||
|
||||
`,
|
||||
},
|
||||
{[]int{3},
|
||||
`Subject: Inside mail 3
|
||||
From: Mary Smith <mary@example.net>
|
||||
To: John Doe <jdoe@machine.example>
|
||||
Date: Fri, 20 Nov 1997 09:55:06 -0600
|
||||
Content-Type: message/rfc822; boundary="0003MSG"
|
||||
|
||||
3. message summary
|
||||
|
||||
--0003MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
3.1 message text
|
||||
|
||||
--0003MSG
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="msg_3_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
3/2/MessageOctestStream/==
|
||||
|
||||
--0003MSG--
|
||||
|
||||
`,
|
||||
`3. message summary
|
||||
|
||||
--0003MSG
|
||||
Content-Type: text/plain
|
||||
|
||||
3.1 message text
|
||||
|
||||
--0003MSG
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="msg_3_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
3/2/MessageOctestStream/==
|
||||
|
||||
--0003MSG--
|
||||
|
||||
`,
|
||||
},
|
||||
{[]int{3, 1},
|
||||
`Content-Type: text/plain
|
||||
|
||||
3.1 message text
|
||||
|
||||
`,
|
||||
`3.1 message text
|
||||
|
||||
`,
|
||||
},
|
||||
{[]int{3, 2},
|
||||
`Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="msg_3_signature.sig"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
3/2/MessageOctestStream/==
|
||||
|
||||
`,
|
||||
`3/2/MessageOctestStream/==
|
||||
|
||||
`,
|
||||
},
|
||||
{[]int{4, 2, 2, 1},
|
||||
`Content-Type: text/plain
|
||||
|
||||
4.2.2.1 plain text
|
||||
|
||||
`,
|
||||
`4.2.2.1 plain text
|
||||
|
||||
`,
|
||||
},
|
||||
{[]int{4, 2, 2, 2},
|
||||
`Content-Type: text/html
|
||||
|
||||
<h1>4.2.2.2 html text</h1>
|
||||
|
||||
`,
|
||||
`<h1>4.2.2.2 html text</h1>
|
||||
|
||||
`,
|
||||
},
|
||||
}
|
||||
24
pkg/mime/Changelog.md
Normal file
24
pkg/mime/Changelog.md
Normal file
@ -0,0 +1,24 @@
|
||||
# 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
|
||||
|
||||
## [2019-12-10] v1.0.2
|
||||
|
||||
### Added
|
||||
* support for shift_JIS (cp932) encoding
|
||||
|
||||
## [2019-09-30] v1.0.1
|
||||
|
||||
### Changed
|
||||
* fix divide by zero
|
||||
|
||||
## [2019-09-26] v1.0.0
|
||||
|
||||
### Changed
|
||||
* Import-Export#192: filter header parameters
|
||||
* ignore twice the same parameter (take the latest)
|
||||
* convert non utf8 RFC2231 parameters to a single line utf8 RFC2231
|
||||
|
||||
254
pkg/mime/encoding.go
Normal file
254
pkg/mime/encoding.go
Normal file
@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
var wordDec = &mime.WordDecoder{
|
||||
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) {
|
||||
dec, err := selectDecoder(charset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dec == nil { // utf-8
|
||||
return input, nil
|
||||
}
|
||||
return dec.Reader(input), nil
|
||||
},
|
||||
}
|
||||
|
||||
// Expects trimmed lowercase.
|
||||
func getEncoding(charset string) (enc encoding.Encoding, err error) {
|
||||
preparsed := strings.Trim(strings.ToLower(charset), " \t\r\n")
|
||||
|
||||
// koi
|
||||
re := regexp.MustCompile("(cs)?koi[-_ ]?8?[-_ ]?(r|ru|u|uk)?$")
|
||||
matches := re.FindAllStringSubmatch(preparsed, -1)
|
||||
if len(matches) == 1 && len(matches[0]) == 3 {
|
||||
preparsed = "koi8-"
|
||||
switch matches[0][2] {
|
||||
case "u", "uk":
|
||||
preparsed += "u"
|
||||
default:
|
||||
preparsed += "r"
|
||||
}
|
||||
}
|
||||
|
||||
// windows-XXXX
|
||||
re = regexp.MustCompile("(cp|(cs)?win(dows)?)[-_ ]?([0-9]{3,4})$")
|
||||
matches = re.FindAllStringSubmatch(preparsed, -1)
|
||||
if len(matches) == 1 && len(matches[0]) == 5 {
|
||||
switch matches[0][4] {
|
||||
case "874", "1250", "1251", "1252", "1253", "1254", "1255", "1256", "1257", "1258":
|
||||
preparsed = "windows-" + matches[0][4]
|
||||
}
|
||||
}
|
||||
|
||||
// iso
|
||||
re = regexp.MustCompile("iso[-_ ]?([0-9]{4})[-_ ]?([0-9]+|jp)?[-_ ]?(i|e)?")
|
||||
matches = re.FindAllStringSubmatch(preparsed, -1)
|
||||
if len(matches) == 1 && len(matches[0]) == 4 {
|
||||
if matches[0][1] == "2022" && matches[0][2] == "jp" {
|
||||
preparsed = "iso-2022-jp"
|
||||
}
|
||||
if matches[0][1] == "8859" {
|
||||
switch matches[0][2] {
|
||||
case "1", "2", "3", "4", "5", "7", "8", "9", "10", "11", "13", "14", "15", "16":
|
||||
preparsed = "iso-8859-" + matches[0][2]
|
||||
if matches[0][3] == "i" {
|
||||
preparsed += "-" + matches[0][3]
|
||||
}
|
||||
case "":
|
||||
preparsed = "iso-8859-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Latin is tricky.
|
||||
re = regexp.MustCompile("^(cs|csiso)?l(atin)?[-_ ]?([0-9]{1,2})$")
|
||||
matches = re.FindAllStringSubmatch(preparsed, -1)
|
||||
if len(matches) == 1 && len(matches[0]) == 4 {
|
||||
switch matches[0][3] {
|
||||
case "1":
|
||||
preparsed = "windows-1252"
|
||||
case "2", "3", "4", "5":
|
||||
preparsed = "iso-8859-" + matches[0][3]
|
||||
case "6":
|
||||
preparsed = "iso-8859-10"
|
||||
case "8":
|
||||
preparsed = "iso-8859-14"
|
||||
case "9":
|
||||
preparsed = "iso-8859-15"
|
||||
case "10":
|
||||
preparsed = "iso-8859-16"
|
||||
}
|
||||
}
|
||||
|
||||
// Missing substitutions.
|
||||
switch preparsed {
|
||||
case "csutf8", "iso-utf-8", "utf8mb4":
|
||||
preparsed = "utf-8"
|
||||
|
||||
case "cp932", "windows-932", "windows-31J", "ibm-943", "cp943":
|
||||
preparsed = "shift_jis"
|
||||
case "eucjp", "ibm-eucjp":
|
||||
preparsed = "euc-jp"
|
||||
case "euckr", "ibm-euckr", "cp949":
|
||||
preparsed = "euc-kr"
|
||||
case "euccn", "ibm-euccn":
|
||||
preparsed = "gbk"
|
||||
case "zht16mswin950", "cp950":
|
||||
preparsed = "big5"
|
||||
|
||||
case "csascii",
|
||||
"ansi_x3.4-1968",
|
||||
"ansi_x3.4-1986",
|
||||
"ansi_x3.110-1983",
|
||||
"cp850",
|
||||
"cp858",
|
||||
"us",
|
||||
"iso646",
|
||||
"iso-646",
|
||||
"iso646-us",
|
||||
"iso_646.irv:1991",
|
||||
"cp367",
|
||||
"ibm367",
|
||||
"ibm-367",
|
||||
"iso-ir-6":
|
||||
preparsed = "ascii"
|
||||
|
||||
case "ibm852":
|
||||
preparsed = "iso-8859-2"
|
||||
case "iso-ir-199", "iso-celtic":
|
||||
preparsed = "iso-8859-14"
|
||||
case "iso-ir-226":
|
||||
preparsed = "iso-8859-16"
|
||||
|
||||
case "macroman":
|
||||
preparsed = "macintosh"
|
||||
}
|
||||
|
||||
enc, _ = htmlindex.Get(preparsed)
|
||||
if enc == nil {
|
||||
err = fmt.Errorf("can not get encodig for '%s' (or '%s')", charset, preparsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func selectDecoder(charset string) (decoder *encoding.Decoder, err error) {
|
||||
var enc encoding.Encoding
|
||||
lcharset := strings.Trim(strings.ToLower(charset), " \t\r\n")
|
||||
switch lcharset {
|
||||
case "utf7", "utf-7", "unicode-1-1-utf-7":
|
||||
return NewUtf7Decoder(), nil
|
||||
default:
|
||||
enc, err = getEncoding(lcharset)
|
||||
}
|
||||
if err == nil {
|
||||
decoder = enc.NewDecoder()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeHeader if needed. Returns error if raw contains non-utf8 characters.
|
||||
func DecodeHeader(raw string) (decoded string, err error) {
|
||||
if decoded, err = wordDec.DecodeHeader(raw); err != nil {
|
||||
decoded = raw
|
||||
}
|
||||
if !utf8.ValidString(decoded) {
|
||||
err = fmt.Errorf("header contains non utf8 chars: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeHeader using quoted printable and utf8
|
||||
func EncodeHeader(s string) string {
|
||||
return mime.QEncoding.Encode("utf-8", s)
|
||||
}
|
||||
|
||||
// DecodeCharset decodes the orginal using content type parameters.
|
||||
// When charset is missing it checks thaht the content is valid utf8.
|
||||
func DecodeCharset(original []byte, contentTypeParams map[string]string) ([]byte, error) {
|
||||
var decoder *encoding.Decoder
|
||||
var err error
|
||||
if charset, ok := contentTypeParams["charset"]; ok {
|
||||
decoder, err = selectDecoder(charset)
|
||||
} else {
|
||||
if utf8.Valid(original) {
|
||||
return original, nil
|
||||
}
|
||||
err = fmt.Errorf("non-utf8 content without charset specification")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
|
||||
utf8 := make([]byte, len(original))
|
||||
nDst, nSrc, err := decoder.Transform(utf8, original, false)
|
||||
for err == transform.ErrShortDst {
|
||||
if nDst < 1 {
|
||||
nDst = 1
|
||||
}
|
||||
if nSrc < 1 {
|
||||
nSrc = 1
|
||||
}
|
||||
utf8 = make([]byte, (nDst/nSrc+1)*len(original))
|
||||
nDst, nSrc, err = decoder.Transform(utf8, original, false)
|
||||
}
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
utf8 = bytes.Trim(utf8, "\x00")
|
||||
|
||||
return utf8, nil
|
||||
}
|
||||
|
||||
// DecodeContentEncoding wraps the reader with decoder based on content encoding.
|
||||
func DecodeContentEncoding(r io.Reader, contentEncoding string) (d io.Reader) {
|
||||
switch strings.ToLower(contentEncoding) {
|
||||
case "quoted-printable":
|
||||
d = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
d = base64.NewDecoder(base64.StdEncoding, r)
|
||||
case "7bit", "8bit", "binary", "": // Nothing to do
|
||||
d = r
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ParseMediaType from MIME doesn't support RFC2231 for non asci / utf8 encodings so we have to pre-parse it.
|
||||
func ParseMediaType(v string) (mediatype string, params map[string]string, err error) {
|
||||
v, _ = changeEncodingAndKeepLastParamDefinition(v)
|
||||
return mime.ParseMediaType(v)
|
||||
}
|
||||
445
pkg/mime/encoding_test.go
Normal file
445
pkg/mime/encoding_test.go
Normal file
@ -0,0 +1,445 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
//"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
|
||||
a "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDecodeHeader(t *testing.T) {
|
||||
testData := []struct{ raw, expected string }{
|
||||
{
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"=?iso-2022-jp?Q?=1B$B!Z=1B(BTimes_Car_PLUS=1B$B![JV5Q>Z=1B(B?=",
|
||||
"【Times Car PLUS】返却証",
|
||||
},
|
||||
{
|
||||
`=?iso-2022-jp?Q?iTunes_Movie_=1B$B%K%e!<%j%j!<%9$HCmL\:nIJ=1B(B?=`,
|
||||
"iTunes Movie ニューリリースと注目作品",
|
||||
},
|
||||
{
|
||||
"=?UTF-8?B?w4TDi8OPw5bDnA==?= =?UTF-8?B?IMOkw6vDr8O2w7w=?=",
|
||||
"ÄËÏÖÜ äëïöü",
|
||||
},
|
||||
{
|
||||
"=?ISO-8859-2?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=",
|
||||
"ÄËIÖÜ äëiöü",
|
||||
},
|
||||
{
|
||||
"=?uknown?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=",
|
||||
"=?uknown?B?xMtJ1tw=?= =?ISO-8859-2?B?IOTrafb8?=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, val := range testData {
|
||||
if decoded, err := DecodeHeader(val.raw); strings.Compare(val.expected, decoded) != 0 {
|
||||
t.Errorf("Incorrect decoding of header %q expected %q but have %q; Error %v", val.raw, val.expected, decoded, err)
|
||||
} else {
|
||||
// fmt.Println("Header", val.raw, "successfully decoded", decoded, ". Error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testParseMediaTypeData struct {
|
||||
arg, wantMediaType string
|
||||
wantParams map[string]string
|
||||
}
|
||||
|
||||
func (d *testParseMediaTypeData) run(t *testing.T) {
|
||||
gotMediaType, params, err := ParseMediaType(d.arg)
|
||||
a.Nil(t, err)
|
||||
a.Equal(t, d.wantMediaType, gotMediaType)
|
||||
a.Equal(t, d.wantParams, params)
|
||||
}
|
||||
|
||||
func TestParseMediaType(t *testing.T) {
|
||||
testTable := map[string]testParseMediaTypeData{
|
||||
"TwiceTheSameParameter": {
|
||||
arg: "attachment; filename=joy.txt; filename=JOY.TXT; title=hi;",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "JOY.TXT", "title": "hi"},
|
||||
},
|
||||
"SingleLineUTF8": {
|
||||
arg: "attachment;\nfilename*=utf-8''%F0%9F%98%81%F0%9F%98%82.txt;\n title=smile",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "😁😂.txt", "title": "smile"},
|
||||
},
|
||||
"MultiLineUTF8": {
|
||||
arg: "attachment;\nfilename*0*=utf-8''%F0%9F%98%81; title=smile;\nfilename*1*=%F0%9F%98%82;\nfilename*2=.txt",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "😁😂.txt", "title": "smile"},
|
||||
},
|
||||
"MultiLineFirstNoEncNextUTF8": {
|
||||
arg: "attachment;\nfilename*0*=utf-8''joy ;\n title*=utf-8''smile; \nfilename*1*=%F0%9F%98%82;\nfilename*2=.txt",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "joy😂.txt", "title": "smile"},
|
||||
},
|
||||
"SingleLineBig5": {
|
||||
arg: "attachment;\nfilename*=big5''%B3%C6%A7%D1%BF%FD.m4a; title*=utf8''memorandum",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "備忘錄.m4a", "title": "memorandum"},
|
||||
},
|
||||
"MultiLineBig5": {
|
||||
arg: "attachment;\nfilename*0*=big5''%B3%C6a; title*0=utf8''memorandum; filename*2=%BF%FD.m4a; \nfilename*1*=%A7%D1b;",
|
||||
wantMediaType: "attachment",
|
||||
wantParams: map[string]string{"filename": "備a忘b錄.m4a", "title": "memorandum"},
|
||||
},
|
||||
}
|
||||
for name, testData := range testTable {
|
||||
t.Run(name, testData.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEncoding(t *testing.T) {
|
||||
// All MIME charsets with aliases can be found here:
|
||||
// https://www.iana.org/assignments/character-sets/character-sets.xhtml
|
||||
mimesets := map[string][]string{
|
||||
"utf-8": []string{ // MIB 16
|
||||
"utf8",
|
||||
"csutf8",
|
||||
"unicode-1-1-utf-8",
|
||||
"iso-utf-8",
|
||||
"utf8mb4",
|
||||
},
|
||||
"gbk": []string{
|
||||
"gb2312", // MIB 2025
|
||||
//"euc-cn": []string{
|
||||
"euccn",
|
||||
"ibm-euccn",
|
||||
},
|
||||
//"utf7": []string{"utf-7", "unicode-1-1-utf-7"},
|
||||
"iso-8859-2": []string{ // MIB 5
|
||||
"iso-ir-101",
|
||||
"iso_8859-2",
|
||||
"iso8859-2",
|
||||
"latin2",
|
||||
"l2",
|
||||
"csisolatin2",
|
||||
"ibm852",
|
||||
//"FAILEDibm852",
|
||||
},
|
||||
"iso-8859-3": []string{ // MIB 6
|
||||
"iso-ir-109",
|
||||
"iso_8859-3",
|
||||
"latin3",
|
||||
"l3",
|
||||
"csisolatin3",
|
||||
},
|
||||
"iso-8859-4": []string{ // MIB 7
|
||||
"iso-ir-110",
|
||||
"iso_8859-4",
|
||||
"latin4",
|
||||
"l4",
|
||||
"csisolatin4",
|
||||
},
|
||||
"iso-8859-5": []string{ // MIB 8
|
||||
"iso-ir-144",
|
||||
"iso_8859-5",
|
||||
"cyrillic",
|
||||
"csisolatincyrillic",
|
||||
},
|
||||
"iso-8859-6": []string{ // MIB 9
|
||||
"iso-ir-127",
|
||||
"iso_8859-6",
|
||||
"ecma-114",
|
||||
"asmo-708",
|
||||
"arabic",
|
||||
"csisolatinarabic",
|
||||
//"iso-8859-6e": []string{ // MIB 81 just direction
|
||||
"csiso88596e",
|
||||
"iso-8859-6-e",
|
||||
//"iso-8859-6i": []string{ // MIB 82
|
||||
"csiso88596i",
|
||||
"iso-8859-6-i"},
|
||||
"iso-8859-7": []string{ // MIB 10
|
||||
"iso-ir-126",
|
||||
"iso_8859-7",
|
||||
"elot_928",
|
||||
"ecma-118",
|
||||
"greek",
|
||||
"greek8",
|
||||
"csisolatingreek"},
|
||||
"iso-8859-8": []string{ // MIB 11
|
||||
"iso-ir-138",
|
||||
"iso_8859-8",
|
||||
"hebrew",
|
||||
"csisolatinhebrew",
|
||||
//"iso-8859-8e": []string{ // MIB 84 (directionality
|
||||
"csiso88598e",
|
||||
"iso-8859-8-e",
|
||||
},
|
||||
"iso-8859-8-i": []string{ // MIB 85
|
||||
"logical",
|
||||
"csiso88598i",
|
||||
"iso-8859-8-i", // Hebrew, the "i" means right-to-left, probably unnecessary with ISO cleaning above.
|
||||
},
|
||||
"iso-8859-10": []string{ // MIB 13
|
||||
"iso-ir-157",
|
||||
"l6",
|
||||
"iso_8859-10:1992",
|
||||
"csisolatin6",
|
||||
"latin6"},
|
||||
"iso-8859-13": []string{ // MIB 109
|
||||
"csiso885913"},
|
||||
"iso-8859-14": []string{ // MIB 110
|
||||
"iso-ir-199",
|
||||
"iso_8859-14:1998",
|
||||
"iso_8859-14",
|
||||
"latin8",
|
||||
"iso-celtic",
|
||||
"l8",
|
||||
"csiso885914"},
|
||||
"iso-8859-15": []string{ // MIB 111
|
||||
"iso_8859-15",
|
||||
"latin-9",
|
||||
"csiso885915",
|
||||
"ISO8859-15"},
|
||||
"iso-8859-16": []string{ // MIB 112
|
||||
"iso-ir-226",
|
||||
"iso_8859-16:2001",
|
||||
"iso_8859-16",
|
||||
"latin10",
|
||||
"l10",
|
||||
"csiso885916",
|
||||
},
|
||||
"windows-874": []string{ // MIB 2109
|
||||
"cswindows874",
|
||||
"cp874",
|
||||
"iso-8859-11",
|
||||
"tis-620",
|
||||
},
|
||||
"windows-1250": []string{ // MIB 2250
|
||||
"cswindows1250",
|
||||
"cp1250",
|
||||
},
|
||||
"windows-1251": []string{ // MIB 2251
|
||||
"cswindows1251",
|
||||
"cp1251",
|
||||
},
|
||||
"windows-1252": []string{ // MIB 2252
|
||||
"cswindows1252",
|
||||
"cp1252",
|
||||
"3dwindows-1252",
|
||||
"we8mswin1252",
|
||||
"us-ascii", // MIB 3
|
||||
"ansi_x3.110-1983", // MIB 74 // usascii
|
||||
//"iso-8859-1": []string{ // MIB 4 succeed by win1252
|
||||
"iso8859-1",
|
||||
"iso-ir-100",
|
||||
"iso_8859-1",
|
||||
"latin1",
|
||||
"l1",
|
||||
"ibm819",
|
||||
"cp819",
|
||||
"csisolatin1",
|
||||
"ansi_x3.4-1968",
|
||||
"ansi_x3.4-1986",
|
||||
"cp850",
|
||||
"cp858", // "cp850" Mostly correct except for the Euro sign.
|
||||
"iso_646.irv:1991",
|
||||
"iso646-us",
|
||||
"us",
|
||||
"ibm367",
|
||||
"cp367",
|
||||
"csascii",
|
||||
"ascii",
|
||||
"iso-ir-6",
|
||||
"we8iso8859p1",
|
||||
},
|
||||
"windows-1253": []string{"cswindows1253", "cp1253"}, // MIB 2253
|
||||
"windows-1254": []string{"cswindows1254", "cp1254"}, // MIB 2254
|
||||
"windows-1255": []string{"cSwindows1255", "cp1255"}, // MIB 2255
|
||||
"windows-1256": []string{"cswIndows1256", "cp1256"}, // MIB 2256
|
||||
"windows-1257": []string{"cswinDows1257", "cp1257"}, // MIB 2257
|
||||
"windows-1258": []string{"cswindoWs1258", "cp1258"}, // MIB 2257
|
||||
"koi8-r": []string{"cskoi8r", "koi8r"}, // MIB 2084
|
||||
"koi8-u": []string{"cskoi8u", "koi8u"}, // MIB 2088
|
||||
"macintosh": []string{"mac", "macroman", "csmacintosh"}, // MIB 2027
|
||||
"big5": []string{
|
||||
"zht16mswin950", // cp950
|
||||
"cp950",
|
||||
},
|
||||
"euc-kr": []string{
|
||||
"euckr", // MIB 38
|
||||
"ibm-euckr",
|
||||
//"uhc": []string{ // Korea
|
||||
"ks_c_5601-1987",
|
||||
"ksc5601",
|
||||
"cp949",
|
||||
},
|
||||
"euc-jp": []string{
|
||||
"eucjp",
|
||||
"ibm-eucjp",
|
||||
},
|
||||
"shift_jis": []string{
|
||||
"CP932",
|
||||
"MS932",
|
||||
"Windows-932",
|
||||
"Windows-31J",
|
||||
"MS_Kanji",
|
||||
"IBM-943",
|
||||
"CP943",
|
||||
},
|
||||
"iso-2022-jp": []string{ // MIB 39
|
||||
"iso2022jp",
|
||||
"csiso2022jp",
|
||||
},
|
||||
}
|
||||
|
||||
for expected, names := range mimesets {
|
||||
expenc, _ := htmlindex.Get(expected)
|
||||
if canonical, err := htmlindex.Name(expenc); canonical != expected || err != nil {
|
||||
t.Fatalf("Error while get canonical name. Expected '%v' but have %v `%#v`: %v", expected, canonical, expenc, err)
|
||||
}
|
||||
for _, name := range names {
|
||||
enc, err := getEncoding(name)
|
||||
if err != nil || enc == nil {
|
||||
t.Errorf("Error while getting encoding for %v returned: '%#v' and error: '%v'", name, enc, err)
|
||||
}
|
||||
if expenc != enc {
|
||||
t.Errorf("For %v expected %v '%v' but have '%v'", name, expected, expenc, enc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sample text for UTF8 http://www.columbia.edu/~fdc/utf8/index.html
|
||||
func TestEncodeReader(t *testing.T) {
|
||||
// define test data
|
||||
testData := []struct {
|
||||
params map[string]string
|
||||
original []byte
|
||||
message string
|
||||
}{
|
||||
// russian
|
||||
{
|
||||
map[string]string{"charset": "koi8-r"},
|
||||
// а, з, б, у, к, а, а, б, в, г, д, е, ё
|
||||
[]byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "KOI8-R"},
|
||||
[]byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "csKOI8R"},
|
||||
[]byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "koi8-u"},
|
||||
[]byte{0xC1, 0xDA, 0xC2, 0xD5, 0xCB, 0xC1, 0xC1, 0xC2, 0xD7, 0xC7, 0xC4, 0xC5, 0xA3},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "iso-8859-5"},
|
||||
// а , з , б , у , к , а , а , б , в , г , д , е , ё
|
||||
[]byte{0xD0, 0xD7, 0xD1, 0xE3, 0xDA, 0xD0, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xF1},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "csWrong"},
|
||||
[]byte{0xD0, 0xD7, 0xD1, 0xE3, 0xDA, 0xD0, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6},
|
||||
"",
|
||||
},
|
||||
{
|
||||
map[string]string{"charset": "utf8"},
|
||||
[]byte{0xD0, 0xB0, 0xD0, 0xB7, 0xD0, 0xB1, 0xD1, 0x83, 0xD0, 0xBA, 0xD0, 0xB0, 0xD0, 0xB0, 0xD0, 0xB1, 0xD0, 0xB2, 0xD0, 0xB3, 0xD0, 0xB4, 0xD0, 0xB5, 0xD1, 0x91},
|
||||
"азбукаабвгдеё",
|
||||
},
|
||||
// czechoslovakia
|
||||
{
|
||||
map[string]string{"charset": "windows-1250"},
|
||||
[]byte{225, 228, 232, 233, 236, 244},
|
||||
"áäčéěô",
|
||||
},
|
||||
// umlauts
|
||||
{
|
||||
map[string]string{"charset": "iso-8859-1"},
|
||||
[]byte{196, 203, 214, 220, 228, 235, 246, 252},
|
||||
"ÄËÖÜäëöü",
|
||||
},
|
||||
// latvia
|
||||
{
|
||||
map[string]string{"charset": "iso-8859-4"},
|
||||
[]byte{224, 239, 243, 182, 254},
|
||||
"āīķļū",
|
||||
},
|
||||
{ // encoded by https://www.motobit.com/util/charset-codepage-conversion.asp
|
||||
map[string]string{"charset": "utf7"},
|
||||
[]byte("He wes Leovena+APA-es sone -- li+APA-e him be Drihten.+A6QDtw- +A7MDuwPOA8MDwwOx- +A7wDvwPF- +A60DtAPJA8MDsQO9- +A7UDuwO7A7cDvQO5A7oDrg-. +BCcENQRABD0ENQQ7BDg- +BDgENwQxBEs- +BDcENAQ1BEEETA- +BDg- +BEIEMAQ8-,+BCcENQRABD0ENQQ7BDg- +BDgENwQxBEs- +BDcENAQ1BEEETA- +BDg- +BEIEMAQ8-,+C68LvguuC7ELvwuoC80LpA- +C64Lygu0C78LlQuzC78LsgvH- +C6QLrgu/C7QLzQuuC8oLtAu/- +C6oLywuyC80- +C4cLqQu/C6QLvgu1C6QLwQ- +C44LmQvNC5ULwQuuC80- +C5ULvgujC8sLrgvN-."),
|
||||
"He wes Leovenaðes sone -- liðe him be Drihten.Τη γλώσσα μου έδωσαν ελληνική. Чернели избы здесь и там,Чернели избы здесь и там,யாமறிந்த மொழிகளிலே தமிழ்மொழி போல் இனிதாவது எங்கும் காணோம்.",
|
||||
},
|
||||
|
||||
// iconv -f UTF8 -t GB2312 utf8.txt | hexdump -v -e '"0x" 1/1 "%x, "'
|
||||
{ // encoded by iconv; dump by `cat gb2312.txt | hexdump -v -e '"0x" 1/1 "%x "'` and reformat; text from https://zh.wikipedia.org/wiki/GB_2312
|
||||
map[string]string{"charset": "GB2312"},
|
||||
[]byte{0x47, 0x42, 0x20, 0x32, 0x33, 0x31, 0x32, 0xb5, 0xc4, 0xb3, 0xf6, 0xcf, 0xd6, 0xa3, 0xac, 0xbb, 0xf9, 0xb1, 0xbe, 0xc2, 0xfa, 0xd7, 0xe3, 0xc1, 0xcb, 0xba, 0xba, 0xd7, 0xd6, 0xb5, 0xc4, 0xbc, 0xc6, 0xcb, 0xe3, 0xbb, 0xfa, 0xb4, 0xa6, 0xc0, 0xed, 0xd0, 0xe8, 0xd2, 0xaa, 0xa3, 0xac, 0xcb, 0xfc, 0xcb, 0xf9, 0xca, 0xd5, 0xc2, 0xbc, 0xb5, 0xc4, 0xba, 0xba, 0xd7, 0xd6, 0xd2, 0xd1, 0xbe, 0xad, 0xb8, 0xb2, 0xb8, 0xc7, 0xd6, 0xd0, 0xb9, 0xfa, 0xb4, 0xf3, 0xc2, 0xbd, 0x39, 0x39, 0x2e, 0x37, 0x35, 0x25, 0xb5, 0xc4, 0xca, 0xb9, 0xd3, 0xc3, 0xc6, 0xb5, 0xc2, 0xca, 0xa1, 0xa3, 0xb5, 0xab, 0xb6, 0xd4, 0xd3, 0xda, 0xc8, 0xcb, 0xc3, 0xfb},
|
||||
"GB 2312的出现,基本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆99.75%的使用频率。但对于人名",
|
||||
},
|
||||
|
||||
{ // encoded by iconv; text from https://jp.wikipedia.org/wiki/Shift_JIS
|
||||
map[string]string{"charset": "shift-jis"},
|
||||
[]byte{0x95, 0xb6, 0x8e, 0x9a, 0x95, 0x84, 0x8d, 0x86, 0x89, 0xbb, 0x95, 0xfb, 0x8e, 0xae, 0x53, 0x68, 0x69, 0x66, 0x74, 0x5f, 0x4a, 0x49, 0x53, 0x82, 0xcc, 0x90, 0xdd, 0x8c, 0x76, 0x8e, 0xd2, 0x82, 0xe7, 0x82, 0xcd, 0x81, 0x41, 0x90, 0xe6, 0x8d, 0x73, 0x82, 0xb5, 0x82, 0xc4, 0x82, 0xe6, 0x82, 0xad, 0x97, 0x98, 0x97, 0x70, 0x82, 0xb3, 0x82, 0xea, 0x82, 0xc4, 0x82, 0xa2, 0x82, 0xbd, 0x4a, 0x49, 0x53, 0x20, 0x43, 0x20, 0x36, 0x32, 0x32, 0x30, 0x81, 0x69, 0x8c, 0xbb, 0x8d, 0xdd, 0x82, 0xcc, 0x4a, 0x49, 0x53, 0x20, 0x58, 0x20, 0x30, 0x32, 0x30, 0x31, 0x81, 0x6a, 0x82, 0xcc, 0x38, 0x83, 0x72, 0x83, 0x62, 0x83, 0x67, 0x95, 0x84, 0x8d, 0x86, 0x81, 0x69, 0x88, 0xc8, 0x89, 0xba, 0x81, 0x75, 0x89, 0x70, 0x90, 0x94, 0x8e, 0x9a, 0x81, 0x45, 0x94, 0xbc, 0x8a, 0x70, 0x83, 0x4a, 0x83, 0x69, 0x81, 0x76, 0x81, 0x6a, 0x82, 0xc6, 0x81, 0x41, 0x4a, 0x49, 0x53, 0x20, 0x43, 0x20, 0x36, 0x32, 0x32, 0x36, 0x81, 0x69, 0x8c, 0xbb, 0x8d, 0xdd, 0x82, 0xcc, 0x4a, 0x49, 0x53, 0x20, 0x58, 0x20, 0x30, 0x32, 0x30, 0x38, 0x81, 0x41, 0x88, 0xc8, 0x89, 0xba, 0x81, 0x75, 0x8a, 0xbf, 0x8e, 0x9a, 0x81, 0x76, 0x81, 0x6a, 0x82, 0xcc, 0x97, 0xbc, 0x95, 0xb6, 0x8e, 0x9a, 0x8f, 0x57, 0x8d, 0x87, 0x82, 0xf0, 0x95, 0x5c, 0x8c, 0xbb, 0x82, 0xb5, 0x82, 0xe6, 0x82, 0xa4, 0x82, 0xc6, 0x82, 0xb5, 0x82, 0xbd, 0x81, 0x42, 0x82, 0xdc, 0x82, 0xbd, 0x81, 0x41, 0x83, 0x74, 0x83, 0x40, 0x83, 0x43, 0x83, 0x8b, 0x82, 0xcc, 0x91, 0xe5, 0x82, 0xab, 0x82, 0xb3, 0x82, 0xe2, 0x8f, 0x88, 0x97, 0x9d, 0x8e, 0x9e, 0x8a, 0xd4, 0x82, 0xcc, 0x92, 0x5a, 0x8f, 0x6b, 0x82, 0xf0, 0x90, 0x7d, 0x82, 0xe9, 0x82, 0xbd, 0x82, 0xdf, 0x81, 0x41, 0x83, 0x47, 0x83, 0x58, 0x83, 0x50, 0x81, 0x5b, 0x83, 0x76, 0x83, 0x56, 0x81, 0x5b, 0x83, 0x50, 0x83, 0x93, 0x83, 0x58, 0x82, 0xc8, 0x82, 0xb5, 0x82, 0xc5, 0x8d, 0xac, 0x8d, 0xdd, 0x89, 0xc2, 0x94, 0x5c, 0x82, 0xc9, 0x82, 0xb7, 0x82, 0xe9, 0x82, 0xb1, 0x82, 0xc6, 0x82, 0xf0, 0x8a, 0xe9, 0x90, 0x7d, 0x82, 0xb5, 0x82, 0xbd, 0x81, 0x42},
|
||||
"文字符号化方式Shift_JISの設計者らは、先行してよく利用されていたJIS C 6220(現在のJIS X 0201)の8ビット符号(以下「英数字・半角カナ」)と、JIS C 6226(現在のJIS X 0208、以下「漢字」)の両文字集合を表現しようとした。また、ファイルの大きさや処理時間の短縮を図るため、エスケープシーケンスなしで混在可能にすることを企図した。",
|
||||
},
|
||||
|
||||
// add more from mutations of https://en.wikipedia.org/wiki/World_Wide_Web
|
||||
|
||||
}
|
||||
|
||||
// run tests
|
||||
for _, val := range testData {
|
||||
//fmt.Println("Testing ", val)
|
||||
expected := []byte(val.message)
|
||||
decoded, err := DecodeCharset(val.original, val.params)
|
||||
if len(expected) == 0 {
|
||||
if err == nil {
|
||||
t.Error("Expected err but have ", err)
|
||||
} else {
|
||||
//fmt.Println("Expected err: ", err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Error("Expected ok but have ", err)
|
||||
}
|
||||
}
|
||||
|
||||
if bytes.Equal(decoded, expected) {
|
||||
// fmt.Println("Succesfull decoding of ", val.params, ":", string(decoded))
|
||||
} else {
|
||||
t.Error("Wrong encoding of ", val.params, ".Expected\n", expected, "\nbut have\n", decoded)
|
||||
}
|
||||
if strings.Compare(val.message, string(decoded)) != 0 {
|
||||
t.Error("Wrong message for ", val.params, ".Expected\n", val.message, "\nbut have\n", string(decoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
364
pkg/mime/mediaType.go
Normal file
364
pkg/mime/mediaType.go
Normal file
@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// changeEncodingAndKeepLastParamDefinition is necessary to modify behaviour
|
||||
// provided by the golang standard libraries.
|
||||
func changeEncodingAndKeepLastParamDefinition(v string) (out string, err error) {
|
||||
log := logrus.WithField("pkg", "pm-mime")
|
||||
|
||||
out = v // By default don't do anything with that.
|
||||
keepOrig := true
|
||||
|
||||
i := strings.Index(v, ";")
|
||||
if i == -1 {
|
||||
i = len(v)
|
||||
}
|
||||
mediatype := strings.TrimSpace(strings.ToLower(v[0:i]))
|
||||
|
||||
params := map[string]string{}
|
||||
var continuation map[string]map[string]string
|
||||
|
||||
v = v[i:]
|
||||
for len(v) > 0 {
|
||||
v = strings.TrimLeftFunc(v, unicode.IsSpace)
|
||||
if len(v) == 0 {
|
||||
break
|
||||
}
|
||||
key, value, rest := consumeMediaParam(v)
|
||||
if key == "" {
|
||||
break
|
||||
}
|
||||
|
||||
pmap := params
|
||||
if idx := strings.Index(key, "*"); idx != -1 {
|
||||
baseName := key[:idx]
|
||||
if continuation == nil {
|
||||
continuation = make(map[string]map[string]string)
|
||||
}
|
||||
var ok bool
|
||||
if pmap, ok = continuation[baseName]; !ok {
|
||||
continuation[baseName] = make(map[string]string)
|
||||
pmap = continuation[baseName]
|
||||
}
|
||||
if isFirstContinuation(key) {
|
||||
charset, _, err := get2231Charset(value)
|
||||
if err != nil {
|
||||
log.Errorln("Filter params:", err)
|
||||
continue
|
||||
}
|
||||
if charset != "utf-8" && charset != "us-ascii" {
|
||||
keepOrig = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := pmap[key]; exists {
|
||||
keepOrig = false
|
||||
}
|
||||
pmap[key] = value
|
||||
v = rest
|
||||
}
|
||||
|
||||
if keepOrig {
|
||||
return
|
||||
}
|
||||
|
||||
if continuation != nil {
|
||||
for paramKey, contMap := range continuation {
|
||||
value, err := mergeContinuations(paramKey, contMap)
|
||||
if err == nil {
|
||||
params[paramKey+"*"] = value
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback.
|
||||
log.Errorln("Merge param", paramKey, ":", err)
|
||||
for ck, cv := range contMap {
|
||||
params[ck] = cv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge ;
|
||||
out = mediatype
|
||||
for k, v := range params {
|
||||
out += ";"
|
||||
out += k
|
||||
out += "="
|
||||
out += v
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func isFirstContinuation(key string) bool {
|
||||
if idx := strings.Index(key, "*"); idx != -1 {
|
||||
return key[idx:] == "*" || key[idx:] == "*0*"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// get2231Charset partially from mime/mediatype.go:211 function `decode2231Enc`.
|
||||
func get2231Charset(v string) (charset, value string, err error) {
|
||||
sv := strings.SplitN(v, "'", 3)
|
||||
if len(sv) != 3 {
|
||||
err = errors.New("incorrect RFC2231 charset format")
|
||||
return
|
||||
}
|
||||
charset = strings.ToLower(sv[0])
|
||||
value = sv[2]
|
||||
return
|
||||
}
|
||||
|
||||
func mergeContinuations(paramKey string, contMap map[string]string) (string, error) {
|
||||
var err error
|
||||
var charset, value string
|
||||
|
||||
// Single value.
|
||||
if contValue, ok := contMap[paramKey+"*"]; ok {
|
||||
if charset, value, err = get2231Charset(contValue); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
for n := 0; ; n++ {
|
||||
contKey := fmt.Sprintf("%s*%d", paramKey, n)
|
||||
contValue, isLast := contMap[contKey]
|
||||
if !isLast {
|
||||
var ok bool
|
||||
contValue, ok = contMap[contKey+"*"]
|
||||
if !ok {
|
||||
return "", errors.New("not valid RFC2231 continuation")
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
if charset, value, err = get2231Charset(contValue); err != nil || charset == "" {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
value += contValue
|
||||
}
|
||||
if isLast {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return convertHexToUTF(charset, value)
|
||||
}
|
||||
|
||||
// convertHexToUTF converts hex values string with charset to UTF8 in RFC2231 format.
|
||||
func convertHexToUTF(charset, value string) (string, error) {
|
||||
raw, err := percentHexUnescape(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
utf8, err := DecodeCharset(raw, map[string]string{"charset": charset})
|
||||
return "utf-8''" + percentHexEscape(utf8), err
|
||||
}
|
||||
|
||||
// consumeMediaParam copy paste mime/mediatype.go:297.
|
||||
func consumeMediaParam(v string) (param, value, rest string) {
|
||||
rest = strings.TrimLeftFunc(v, unicode.IsSpace)
|
||||
if !strings.HasPrefix(rest, ";") {
|
||||
return "", "", v
|
||||
}
|
||||
|
||||
rest = rest[1:] // Consume semicolon.
|
||||
rest = strings.TrimLeftFunc(rest, unicode.IsSpace)
|
||||
param, rest = consumeToken(rest)
|
||||
param = strings.ToLower(param)
|
||||
if param == "" {
|
||||
return "", "", v
|
||||
}
|
||||
|
||||
rest = strings.TrimLeftFunc(rest, unicode.IsSpace)
|
||||
if !strings.HasPrefix(rest, "=") {
|
||||
return "", "", v
|
||||
}
|
||||
rest = rest[1:] // Consume equals sign.
|
||||
rest = strings.TrimLeftFunc(rest, unicode.IsSpace)
|
||||
value, rest2 := consumeValue(rest)
|
||||
if value == "" && rest2 == rest {
|
||||
return "", "", v
|
||||
}
|
||||
rest = rest2
|
||||
return param, value, rest
|
||||
}
|
||||
|
||||
// consumeToken copy paste mime/mediatype.go:238.
|
||||
// consumeToken consumes a token from the beginning of the provided string,
|
||||
// per RFC 2045 section 5.1 (referenced from 2183), and returns
|
||||
// the token consumed and the rest of the string.
|
||||
// Returns ("", v) on failure to consume at least one character.
|
||||
func consumeToken(v string) (token, rest string) {
|
||||
notPos := strings.IndexFunc(v, isNotTokenChar)
|
||||
if notPos == -1 {
|
||||
return v, ""
|
||||
}
|
||||
if notPos == 0 {
|
||||
return "", v
|
||||
}
|
||||
return v[0:notPos], v[notPos:]
|
||||
}
|
||||
|
||||
// consumeValue copy paste mime/mediatype.go:253
|
||||
// consumeValue consumes a "value" per RFC 2045, where a value is
|
||||
// either a 'token' or a 'quoted-string'. On success, consumeValue
|
||||
// returns the value consumed (and de-quoted/escaped, if a
|
||||
// quoted-string) and the rest of the string.
|
||||
// On failure, returns ("", v).
|
||||
func consumeValue(v string) (value, rest string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
if v[0] != '"' {
|
||||
return consumeToken(v)
|
||||
}
|
||||
|
||||
// parse a quoted-string
|
||||
buffer := new(strings.Builder)
|
||||
for i := 1; i < len(v); i++ {
|
||||
r := v[i]
|
||||
if r == '"' {
|
||||
return buffer.String(), v[i+1:]
|
||||
}
|
||||
// When MSIE sends a full file path (in "intranet mode"), it does not
|
||||
// escape backslashes: "C:\dev\go\foo.txt", not "C:\\dev\\go\\foo.txt".
|
||||
//
|
||||
// No known MIME generators emit unnecessary backslash escapes
|
||||
// for simple token characters like numbers and letters.
|
||||
//
|
||||
// If we see an unnecessary backslash escape, assume it is from MSIE
|
||||
// and intended as a literal backslash. This makes Go servers deal better
|
||||
// with MSIE without affecting the way they handle conforming MIME
|
||||
// generators.
|
||||
if r == '\\' && i+1 < len(v) && !isTokenChar(rune(v[i+1])) {
|
||||
buffer.WriteByte(v[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if r == '\r' || r == '\n' {
|
||||
return "", v
|
||||
}
|
||||
buffer.WriteByte(v[i])
|
||||
}
|
||||
// Did not find end quote.
|
||||
return "", v
|
||||
}
|
||||
|
||||
// isNotTokenChar copy paste from mime/mediatype.go:234.
|
||||
func isNotTokenChar(r rune) bool {
|
||||
return !isTokenChar(r)
|
||||
}
|
||||
|
||||
// isTokenChar copy paste from mime/grammar.go:19.
|
||||
// isTokenChar reports whether rune is in 'token' as defined by RFC 1521 and RFC 2045.
|
||||
func isTokenChar(r rune) bool {
|
||||
// token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
|
||||
// or tspecials>
|
||||
return r > 0x20 && r < 0x7f && !isTSpecial(r)
|
||||
}
|
||||
|
||||
// isTSpecial copy paste from mime/grammar.go:13
|
||||
// isTSpecial reports whether rune is in 'tspecials' as defined by RFC
|
||||
// 1521 and RFC 2045.
|
||||
func isTSpecial(r rune) bool {
|
||||
return strings.ContainsRune(`()<>@,;:\"/[]?=`, r)
|
||||
}
|
||||
|
||||
func percentHexEscape(raw []byte) (out string) {
|
||||
for _, v := range raw {
|
||||
out += fmt.Sprintf("%%%x", v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// percentHexUnescape copy paste from mime/mediatype.go:325.
|
||||
func percentHexUnescape(s string) ([]byte, error) {
|
||||
// Count %, check that they're well-formed.
|
||||
percents := 0
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] != '%' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
percents++
|
||||
if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) {
|
||||
s = s[i:]
|
||||
if len(s) > 3 {
|
||||
s = s[0:3]
|
||||
}
|
||||
return []byte{}, fmt.Errorf("mime: bogus characters after %%: %q", s)
|
||||
}
|
||||
i += 3
|
||||
}
|
||||
if percents == 0 {
|
||||
return []byte(s), nil
|
||||
}
|
||||
|
||||
t := make([]byte, len(s)-2*percents)
|
||||
j := 0
|
||||
for i := 0; i < len(s); {
|
||||
switch s[i] {
|
||||
case '%':
|
||||
t[j] = unhex(s[i+1])<<4 | unhex(s[i+2])
|
||||
j++
|
||||
i += 3
|
||||
default:
|
||||
t[j] = s[i]
|
||||
j++
|
||||
i++
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ishex copy paste from mime/mediatype.go:364.
|
||||
func ishex(c byte) bool {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
return true
|
||||
case 'a' <= c && c <= 'f':
|
||||
return true
|
||||
case 'A' <= c && c <= 'F':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unhex copy paste from mime/mediatype.go:376.
|
||||
func unhex(c byte) byte {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
return c - '0'
|
||||
case 'a' <= c && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
case 'A' <= c && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
544
pkg/mime/parser.go
Normal file
544
pkg/mime/parser.go
Normal file
@ -0,0 +1,544 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// VisitAcceptor decides what to do with part which is processed.
|
||||
// It is used by MIMEVisitor.
|
||||
type VisitAcceptor interface {
|
||||
Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error)
|
||||
}
|
||||
|
||||
func VisitAll(part io.Reader, h textproto.MIMEHeader, accepter VisitAcceptor) (err error) {
|
||||
mediaType, _, err := getContentType(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return accepter.Accept(part, h, mediaType == "text/plain", true, true)
|
||||
}
|
||||
|
||||
func IsLeaf(h textproto.MIMEHeader) bool {
|
||||
return !strings.HasPrefix(h.Get("Content-Type"), "multipart/")
|
||||
}
|
||||
|
||||
// MIMEVisitor is main object to parse (visit) and process (accept) all parts of MIME message.
|
||||
type MimeVisitor struct {
|
||||
target VisitAcceptor
|
||||
}
|
||||
|
||||
// Accept reads part recursively if needed.
|
||||
// hasPlainSibling is there when acceptor want to check alternatives.
|
||||
func (mv *MimeVisitor) Accept(part io.Reader, h textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) {
|
||||
if !isFirst {
|
||||
return
|
||||
}
|
||||
|
||||
parentMediaType, params, err := getContentType(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = mv.target.Accept(part, h, hasPlainSibling, true, false); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !IsLeaf(h) {
|
||||
var multiparts []io.Reader
|
||||
var multipartHeaders []textproto.MIMEHeader
|
||||
if multiparts, multipartHeaders, err = GetMultipartParts(part, params); err != nil {
|
||||
return
|
||||
}
|
||||
hasPlainChild := false
|
||||
for _, header := range multipartHeaders {
|
||||
mediaType, _, _ := getContentType(header)
|
||||
if mediaType == "text/plain" {
|
||||
hasPlainChild = true
|
||||
}
|
||||
}
|
||||
if hasPlainSibling && parentMediaType == "multipart/related" {
|
||||
hasPlainChild = true
|
||||
}
|
||||
|
||||
for i, p := range multiparts {
|
||||
if err = mv.Accept(p, multipartHeaders[i], hasPlainChild, true, true); err != nil {
|
||||
return
|
||||
}
|
||||
if err = mv.target.Accept(part, h, hasPlainSibling, false, i == (len(multiparts)-1)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NewMIMEVisitor returns a new mime visitor initialised with an acceptor.
|
||||
func NewMimeVisitor(targetAccepter VisitAcceptor) *MimeVisitor {
|
||||
return &MimeVisitor{targetAccepter}
|
||||
}
|
||||
|
||||
func GetRawMimePart(rawdata io.Reader, boundary string) (io.Reader, io.Reader) {
|
||||
b, _ := ioutil.ReadAll(rawdata)
|
||||
tee := bytes.NewReader(b)
|
||||
|
||||
reader := bufio.NewReader(bytes.NewReader(b))
|
||||
byteBoundary := []byte(boundary)
|
||||
bodyBuffer := &bytes.Buffer{}
|
||||
for {
|
||||
line, _, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return tee, bytes.NewReader(bodyBuffer.Bytes())
|
||||
}
|
||||
if bytes.HasPrefix(line, byteBoundary) {
|
||||
break
|
||||
}
|
||||
}
|
||||
lineEndingLength := 0
|
||||
for {
|
||||
line, isPrefix, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return tee, bytes.NewReader(bodyBuffer.Bytes())
|
||||
}
|
||||
if bytes.HasPrefix(line, byteBoundary) {
|
||||
break
|
||||
}
|
||||
lineEndingLength = 0
|
||||
bodyBuffer.Write(line)
|
||||
if !isPrefix {
|
||||
reader.UnreadByte()
|
||||
reader.UnreadByte()
|
||||
token, _ := reader.ReadByte()
|
||||
if token == '\r' {
|
||||
lineEndingLength++
|
||||
bodyBuffer.WriteByte(token)
|
||||
}
|
||||
lineEndingLength++
|
||||
bodyBuffer.WriteByte(token)
|
||||
}
|
||||
}
|
||||
ioutil.ReadAll(reader)
|
||||
data := bodyBuffer.Bytes()
|
||||
return tee, bytes.NewReader(data[0 : len(data)-lineEndingLength])
|
||||
}
|
||||
|
||||
func GetAllChildParts(part io.Reader, h textproto.MIMEHeader) (parts []io.Reader, headers []textproto.MIMEHeader, err error) {
|
||||
mediaType, params, err := getContentType(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
var multiparts []io.Reader
|
||||
var multipartHeaders []textproto.MIMEHeader
|
||||
if multiparts, multipartHeaders, err = GetMultipartParts(part, params); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.Contains(mediaType, "alternative") {
|
||||
var chosenPart io.Reader
|
||||
var chosenHeader textproto.MIMEHeader
|
||||
if chosenPart, chosenHeader, err = pickAlternativePart(multiparts, multipartHeaders); err != nil {
|
||||
return
|
||||
}
|
||||
var childParts []io.Reader
|
||||
var childHeaders []textproto.MIMEHeader
|
||||
if childParts, childHeaders, err = GetAllChildParts(chosenPart, chosenHeader); err != nil {
|
||||
return
|
||||
}
|
||||
parts = append(parts, childParts...)
|
||||
headers = append(headers, childHeaders...)
|
||||
} else {
|
||||
for i, p := range multiparts {
|
||||
var childParts []io.Reader
|
||||
var childHeaders []textproto.MIMEHeader
|
||||
if childParts, childHeaders, err = GetAllChildParts(p, multipartHeaders[i]); err != nil {
|
||||
return
|
||||
}
|
||||
parts = append(parts, childParts...)
|
||||
headers = append(headers, childHeaders...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, part)
|
||||
headers = append(headers, h)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetMultipartParts(r io.Reader, params map[string]string) (parts []io.Reader, headers []textproto.MIMEHeader, err error) {
|
||||
mr := multipart.NewReader(r, params["boundary"])
|
||||
parts = []io.Reader{}
|
||||
headers = []textproto.MIMEHeader{}
|
||||
var p *multipart.Part
|
||||
for {
|
||||
p, err = mr.NextPart()
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b, _ := ioutil.ReadAll(p)
|
||||
buffer := bytes.NewBuffer(b)
|
||||
|
||||
parts = append(parts, buffer)
|
||||
headers = append(headers, p.Header)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func pickAlternativePart(parts []io.Reader, headers []textproto.MIMEHeader) (part io.Reader, h textproto.MIMEHeader, err error) {
|
||||
|
||||
for i, h := range headers {
|
||||
mediaType, _, err := getContentType(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
return parts[i], headers[i], nil
|
||||
}
|
||||
}
|
||||
for i, h := range headers {
|
||||
mediaType, _, err := getContentType(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if mediaType == "text/html" {
|
||||
return parts[i], headers[i], nil
|
||||
}
|
||||
}
|
||||
for i, h := range headers {
|
||||
mediaType, _, err := getContentType(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if mediaType == "text/plain" {
|
||||
return parts[i], headers[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we get all the way here, part will be nil.
|
||||
return
|
||||
}
|
||||
|
||||
// "Parse address comment" as defined in http://tools.wordtothewise.com/rfc/822
|
||||
// FIXME: Does not work for address groups.
|
||||
// NOTE: This should be removed for go>1.10 (please check).
|
||||
func parseAddressComment(raw string) string {
|
||||
parsed := []string{}
|
||||
for _, item := range regexp.MustCompile("[,;]").Split(raw, -1) {
|
||||
re := regexp.MustCompile("[(][^)]*[)]")
|
||||
comments := strings.Join(re.FindAllString(item, -1), " ")
|
||||
comments = strings.Replace(comments, "(", "", -1)
|
||||
comments = strings.Replace(comments, ")", "", -1)
|
||||
withoutComments := re.ReplaceAllString(item, "")
|
||||
addr, err := mail.ParseAddress(withoutComments)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.Name == "" {
|
||||
addr.Name = comments
|
||||
}
|
||||
parsed = append(parsed, addr.String())
|
||||
}
|
||||
return strings.Join(parsed, ", ")
|
||||
}
|
||||
|
||||
func checkHeaders(headers []textproto.MIMEHeader) bool {
|
||||
foundAttachment := false
|
||||
|
||||
for i := 0; i < len(headers); i++ {
|
||||
h := headers[i]
|
||||
|
||||
mediaType, _, _ := getContentType(h)
|
||||
|
||||
if !strings.HasPrefix(mediaType, "text/") {
|
||||
foundAttachment = true
|
||||
} else if foundAttachment {
|
||||
// This means that there is a text part after the first attachment,
|
||||
// so we will have to convert the body from plain->HTML.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodePart(partReader io.Reader, header textproto.MIMEHeader) (decodedPart io.Reader) {
|
||||
decodedPart = DecodeContentEncoding(partReader, header.Get("Content-Transfer-Encoding"))
|
||||
if decodedPart == nil {
|
||||
log.Warnf("Unsupported Content-Transfer-Encoding '%v'", header.Get("Content-Transfer-Encoding"))
|
||||
decodedPart = partReader
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Assume 'text/plain' if missing.
|
||||
func getContentType(header textproto.MIMEHeader) (mediatype string, params map[string]string, err error) {
|
||||
contentType := header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
return mime.ParseMediaType(contentType)
|
||||
}
|
||||
|
||||
// ===================== MIME Printer ===================================
|
||||
// Simply print resulting MIME tree into text form.
|
||||
// TODO move this to file mime_printer.go.
|
||||
|
||||
type stack []string
|
||||
|
||||
func (s stack) Push(v string) stack {
|
||||
return append(s, v)
|
||||
}
|
||||
func (s stack) Pop() (stack, string) {
|
||||
l := len(s)
|
||||
return s[:l-1], s[l-1]
|
||||
}
|
||||
func (s stack) Peek() string {
|
||||
return s[len(s)-1]
|
||||
}
|
||||
|
||||
type MIMEPrinter struct {
|
||||
result *bytes.Buffer
|
||||
boundaryStack stack
|
||||
}
|
||||
|
||||
func NewMIMEPrinter() (pd *MIMEPrinter) {
|
||||
return &MIMEPrinter{
|
||||
result: bytes.NewBuffer([]byte("")),
|
||||
boundaryStack: stack{},
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *MIMEPrinter) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) {
|
||||
if isFirst {
|
||||
http.Header(header).Write(pd.result)
|
||||
pd.result.Write([]byte("\n"))
|
||||
if IsLeaf(header) {
|
||||
pd.result.ReadFrom(partReader)
|
||||
} else {
|
||||
_, params, _ := getContentType(header)
|
||||
boundary := params["boundary"]
|
||||
pd.boundaryStack = pd.boundaryStack.Push(boundary)
|
||||
pd.result.Write([]byte("\nThis is a multi-part message in MIME format.\n--" + boundary + "\n"))
|
||||
}
|
||||
} else {
|
||||
if !isLast {
|
||||
pd.result.Write([]byte("\n--" + pd.boundaryStack.Peek() + "\n"))
|
||||
} else {
|
||||
var boundary string
|
||||
pd.boundaryStack, boundary = pd.boundaryStack.Pop()
|
||||
pd.result.Write([]byte("\n--" + boundary + "--\n.\n"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pd *MIMEPrinter) String() string {
|
||||
return pd.result.String()
|
||||
}
|
||||
|
||||
// ======================== PlainText Collector =========================
|
||||
// Collect contents of all non-attachment text/plain parts and return it as a string.
|
||||
// TODO move this to file collector_plaintext.go.
|
||||
|
||||
type PlainTextCollector struct {
|
||||
target VisitAcceptor
|
||||
plainTextContents *bytes.Buffer
|
||||
}
|
||||
|
||||
func NewPlainTextCollector(targetAccepter VisitAcceptor) *PlainTextCollector {
|
||||
return &PlainTextCollector{
|
||||
target: targetAccepter,
|
||||
plainTextContents: bytes.NewBuffer([]byte("")),
|
||||
}
|
||||
}
|
||||
|
||||
func (ptc *PlainTextCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) {
|
||||
if isFirst {
|
||||
if IsLeaf(header) {
|
||||
mediaType, params, _ := getContentType(header)
|
||||
disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition"))
|
||||
if mediaType == "text/plain" && disp != "attachment" {
|
||||
partData, _ := ioutil.ReadAll(partReader)
|
||||
decodedPart := decodePart(bytes.NewReader(partData), header)
|
||||
|
||||
if buffer, err := ioutil.ReadAll(decodedPart); err == nil {
|
||||
buffer, err = DecodeCharset(buffer, params)
|
||||
if err != nil {
|
||||
log.Warnln("Decode charset error:", err)
|
||||
return err
|
||||
}
|
||||
ptc.plainTextContents.Write(buffer)
|
||||
}
|
||||
|
||||
err = ptc.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err = ptc.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
|
||||
func (ptc PlainTextCollector) GetPlainText() string {
|
||||
return ptc.plainTextContents.String()
|
||||
}
|
||||
|
||||
// ======================== Body Collector ==============
|
||||
// Collect contents of all non-attachment parts and return it as a string.
|
||||
// TODO move this to file collector_body.go.
|
||||
|
||||
type BodyCollector struct {
|
||||
target VisitAcceptor
|
||||
htmlBodyBuffer *bytes.Buffer
|
||||
plainBodyBuffer *bytes.Buffer
|
||||
htmlHeaderBuffer *bytes.Buffer
|
||||
plainHeaderBuffer *bytes.Buffer
|
||||
hasHtml bool
|
||||
}
|
||||
|
||||
func NewBodyCollector(targetAccepter VisitAcceptor) *BodyCollector {
|
||||
return &BodyCollector{
|
||||
target: targetAccepter,
|
||||
htmlBodyBuffer: bytes.NewBuffer([]byte("")),
|
||||
plainBodyBuffer: bytes.NewBuffer([]byte("")),
|
||||
htmlHeaderBuffer: bytes.NewBuffer([]byte("")),
|
||||
plainHeaderBuffer: bytes.NewBuffer([]byte("")),
|
||||
}
|
||||
}
|
||||
|
||||
func (bc *BodyCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) {
|
||||
// TODO: Collect html and plaintext - if there's html with plain sibling don't include plain/text.
|
||||
if isFirst {
|
||||
if IsLeaf(header) {
|
||||
mediaType, params, _ := getContentType(header)
|
||||
disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition"))
|
||||
if disp != "attachment" {
|
||||
partData, _ := ioutil.ReadAll(partReader)
|
||||
decodedPart := decodePart(bytes.NewReader(partData), header)
|
||||
if buffer, err := ioutil.ReadAll(decodedPart); err == nil {
|
||||
buffer, err = DecodeCharset(buffer, params)
|
||||
if err != nil {
|
||||
log.Warnln("Decode charset error:", err)
|
||||
return err
|
||||
}
|
||||
if mediaType == "text/html" {
|
||||
bc.hasHtml = true
|
||||
http.Header(header).Write(bc.htmlHeaderBuffer)
|
||||
bc.htmlBodyBuffer.Write(buffer)
|
||||
} else if mediaType == "text/plain" {
|
||||
http.Header(header).Write(bc.plainHeaderBuffer)
|
||||
bc.plainBodyBuffer.Write(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
err = bc.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err = bc.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
|
||||
func (bc *BodyCollector) GetBody() (string, string) {
|
||||
if bc.hasHtml {
|
||||
return bc.htmlBodyBuffer.String(), "text/html"
|
||||
} else {
|
||||
return bc.plainBodyBuffer.String(), "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
func (bc *BodyCollector) GetHeaders() string {
|
||||
if bc.hasHtml {
|
||||
return bc.htmlHeaderBuffer.String()
|
||||
} else {
|
||||
return bc.plainHeaderBuffer.String()
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Attachments Collector ==============
|
||||
// Collect contents of all attachment parts and return them as a string.
|
||||
// TODO move this to file collector_attachment.go.
|
||||
|
||||
type AttachmentsCollector struct {
|
||||
target VisitAcceptor
|
||||
attBuffers []string
|
||||
attHeaders []string
|
||||
}
|
||||
|
||||
func NewAttachmentsCollector(targetAccepter VisitAcceptor) *AttachmentsCollector {
|
||||
return &AttachmentsCollector{
|
||||
target: targetAccepter,
|
||||
attBuffers: []string{},
|
||||
attHeaders: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AttachmentsCollector) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) (err error) {
|
||||
if isFirst {
|
||||
if IsLeaf(header) {
|
||||
mediaType, params, _ := getContentType(header)
|
||||
disp, _, _ := mime.ParseMediaType(header.Get("Content-Disposition"))
|
||||
if (mediaType != "text/html" && mediaType != "text/plain") || disp == "attachment" {
|
||||
partData, _ := ioutil.ReadAll(partReader)
|
||||
decodedPart := decodePart(bytes.NewReader(partData), header)
|
||||
|
||||
if buffer, err := ioutil.ReadAll(decodedPart); err == nil {
|
||||
buffer, err = DecodeCharset(buffer, params)
|
||||
if err != nil {
|
||||
log.Warnln("Decode charset error:", err)
|
||||
return err
|
||||
}
|
||||
headerBuf := new(bytes.Buffer)
|
||||
http.Header(header).Write(headerBuf)
|
||||
ac.attHeaders = append(ac.attHeaders, headerBuf.String())
|
||||
ac.attBuffers = append(ac.attBuffers, string(buffer))
|
||||
}
|
||||
|
||||
err = ac.target.Accept(bytes.NewReader(partData), header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err = ac.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast)
|
||||
return
|
||||
}
|
||||
|
||||
func (ac AttachmentsCollector) GetAttachments() []string {
|
||||
return ac.attBuffers
|
||||
}
|
||||
|
||||
func (ac AttachmentsCollector) GetAttHeaders() []string {
|
||||
return ac.attHeaders
|
||||
}
|
||||
228
pkg/mime/parser_test.go
Normal file
228
pkg/mime/parser_test.go
Normal file
@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func minimalParse(mimeBody string) (readBody string, plainContents string, err error) {
|
||||
mm, err := mail.ReadMessage(strings.NewReader(mimeBody))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader(mm.Header)
|
||||
mmBodyData, err := ioutil.ReadAll(mm.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
printAccepter := NewMIMEPrinter()
|
||||
plainTextCollector := NewPlainTextCollector(printAccepter)
|
||||
visitor := NewMimeVisitor(plainTextCollector)
|
||||
err = VisitAll(bytes.NewReader(mmBodyData), h, visitor)
|
||||
|
||||
readBody = printAccepter.String()
|
||||
plainContents = plainTextCollector.GetPlainText()
|
||||
|
||||
return readBody, plainContents, err
|
||||
}
|
||||
|
||||
func androidParse(mimeBody string) (body, headers string, atts, attHeaders []string, err error) {
|
||||
mm, err := mail.ReadMessage(strings.NewReader(mimeBody))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader(mm.Header)
|
||||
mmBodyData, err := ioutil.ReadAll(mm.Body)
|
||||
|
||||
printAccepter := NewMIMEPrinter()
|
||||
bodyCollector := NewBodyCollector(printAccepter)
|
||||
attachmentsCollector := NewAttachmentsCollector(bodyCollector)
|
||||
mimeVisitor := NewMimeVisitor(attachmentsCollector)
|
||||
err = VisitAll(bytes.NewReader(mmBodyData), h, mimeVisitor)
|
||||
|
||||
body, _ = bodyCollector.GetBody()
|
||||
headers = bodyCollector.GetHeaders()
|
||||
atts = attachmentsCollector.GetAttachments()
|
||||
attHeaders = attachmentsCollector.GetAttHeaders()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestParseBoundaryIsEmpty(t *testing.T) {
|
||||
testMessage :=
|
||||
`Date: Sun, 10 Mar 2019 11:10:06 -0600
|
||||
In-Reply-To: <abcbase64@protonmail.com>
|
||||
X-Original-To: enterprise@protonmail.com
|
||||
References: <abc64@unicoderns.com> <abc63@protonmail.com> <abc64@protonmail.com> <abc65@mail.gmail.com> <abc66@protonmail.com>
|
||||
To: "ProtonMail" <enterprise@protonmail.com>
|
||||
X-Pm-Origin: external
|
||||
Delivered-To: enterprise@protonmail.com
|
||||
Content-Type: multipart/mixed; boundary=ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7
|
||||
Reply-To: XYZ <xyz@xyz.com>
|
||||
Mime-Version: 1.0
|
||||
Subject: Encrypted Message
|
||||
Return-Path: <xyz@xyz.com>
|
||||
From: XYZ <xyz@xyz.com>
|
||||
X-Pm-Conversationid-Id: gNX9bDPLmBgFZ-C3Tdlb628cas1Xl0m4dql5nsWzQAEI-WQv0ytfwPR4-PWELEK0_87XuFOgetc239Y0pjPYHQ==
|
||||
X-Pm-Date: Sun, 10 Mar 2019 18:10:06 +0100
|
||||
Message-Id: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com>
|
||||
X-Pm-Transfer-Encryption: TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)
|
||||
X-Pm-External-Id: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com>
|
||||
X-Pm-Internal-Id: _iJ8ETxcqXTSK8IzCn0qFpMUTwvRf-xJUtldRA1f6yHdmXjXzKleG3F_NLjZL3FvIWVHoItTxOuuVXcukwwW3g==
|
||||
Openpgp: preference=signencrypt
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.4.0
|
||||
X-Pm-Content-Encryption: end-to-end
|
||||
|
||||
--ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7
|
||||
Content-Disposition: inline
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: multipart/mixed; charset=utf-8
|
||||
|
||||
Content-Type: multipart/mixed; boundary="xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy";
|
||||
protected-headers="v1"
|
||||
From: XYZ <xyz@xyz.com>
|
||||
To: "ProtonMail" <enterprise@protonmail.com>
|
||||
Subject: Encrypted Message
|
||||
Message-ID: <68c11e46-e611-d9e4-edc1-5ec96bac77cc@unicoderns.com>
|
||||
References: <abc64@unicoderns.com> <abc63@protonmail.com> <abc64@protonmail.com> <abc65@mail.gmail.com> <abc66@protonmail.com>
|
||||
In-Reply-To: <abcbase64@protonmail.com>
|
||||
|
||||
--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy
|
||||
Content-Type: text/rfc822-headers; protected-headers="v1"
|
||||
Content-Disposition: inline
|
||||
|
||||
From: XYZ <xyz@xyz.com>
|
||||
To: ProtonMail <enterprise@protonmail.com>
|
||||
Subject: Re: Encrypted Message
|
||||
|
||||
--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="------------F9E5AA6D49692F51484075E3"
|
||||
Content-Language: en-US
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--------------F9E5AA6D49692F51484075E3
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Hi ...
|
||||
|
||||
--------------F9E5AA6D49692F51484075E3
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body text=3D"#000000" bgcolor=3D"#FFFFFF">
|
||||
<p>Hi .. </p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--------------F9E5AA6D49692F51484075E3--
|
||||
|
||||
--xnAIW3Turb9YQZ2rXc2ZGZH45WepHIZyy--
|
||||
|
||||
--ac7e36bd45425e70b4dab2128f34172e4dc3f9ff2eeb47e909267d4252794ec7--
|
||||
|
||||
|
||||
`
|
||||
|
||||
body, content, err := minimalParse(testMessage)
|
||||
if err == nil {
|
||||
t.Fatal("should have error but is", err)
|
||||
}
|
||||
t.Log("==BODY==")
|
||||
t.Log(body)
|
||||
t.Log("==CONTENT==")
|
||||
t.Log(content)
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
testMessage :=
|
||||
`From: John Doe <example@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed;
|
||||
boundary="XXXXboundary text"
|
||||
|
||||
This is a multipart message in MIME format.
|
||||
|
||||
--XXXXboundary text
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
this is the body text
|
||||
|
||||
--XXXXboundary text
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body>this is the html body text</body></html>
|
||||
|
||||
--XXXXboundary text
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Disposition: attachment;
|
||||
filename="test.txt"
|
||||
|
||||
this is the attachment text
|
||||
|
||||
--XXXXboundary text--
|
||||
|
||||
|
||||
`
|
||||
body, heads, att, attHeads, err := androidParse(testMessage)
|
||||
if err != nil {
|
||||
t.Error("parse error", err)
|
||||
}
|
||||
|
||||
fmt.Println("==BODY:")
|
||||
fmt.Println(body)
|
||||
fmt.Println("==BODY HEADERS:")
|
||||
fmt.Println(heads)
|
||||
|
||||
fmt.Println("==ATTACHMENTS:")
|
||||
fmt.Println(att)
|
||||
fmt.Println("==ATTACHMENT HEADERS:")
|
||||
fmt.Println(attHeads)
|
||||
}
|
||||
|
||||
func TestParseAddressComment(t *testing.T) {
|
||||
parsingExamples := map[string]string{
|
||||
"": "",
|
||||
"(Only Comment) here@pm.me": "\"Only Comment\" <here@pm.me>",
|
||||
"Normal Name (With Comment) <here@pm.me>": "\"Normal Name\" <here@pm.me>",
|
||||
"<Muhammed.(I am the greatest)Ali@(the)Vegas.WBA>": "\"I am the greatest the\" <Muhammed.Ali@Vegas.WBA>",
|
||||
}
|
||||
|
||||
for raw, expected := range parsingExamples {
|
||||
parsed := parseAddressComment(raw)
|
||||
if expected != parsed {
|
||||
t.Errorf("When parsing %q expected %q but have %q", raw, expected, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
pkg/mime/utf7Decoder.go
Normal file
188
pkg/mime/utf7Decoder.go
Normal file
@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2020 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 pmmime
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// utf7Decoder copied from: https://github.com/cention-sany/utf7/blob/master/utf7.go
|
||||
// We need `encoding.Decoder` instead of function `UTF7DecodeBytes`.
|
||||
type utf7Decoder struct {
|
||||
transform.NopResetter
|
||||
}
|
||||
|
||||
// NewUtf7Decoder returns a new decoder for utf7.
|
||||
func NewUtf7Decoder() *encoding.Decoder {
|
||||
return &encoding.Decoder{Transformer: utf7Decoder{}}
|
||||
}
|
||||
|
||||
const (
|
||||
uRepl = '\uFFFD' // Unicode replacement code point
|
||||
u7min = 0x20 // Minimum self-representing UTF-7 value
|
||||
u7max = 0x7E // Maximum self-representing UTF-7 value
|
||||
)
|
||||
|
||||
// ErrBadUTF7 is returned to indicate the invalid modified UTF-7 encoding.
|
||||
var ErrBadUTF7 = errors.New("utf7: bad utf-7 encoding")
|
||||
|
||||
const modifiedbase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
var u7enc = base64.NewEncoding(modifiedbase64)
|
||||
|
||||
func isModifiedBase64(r byte) bool {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return true
|
||||
} else if r >= 'a' && r <= 'z' {
|
||||
return true
|
||||
} else if r >= '0' && r <= '9' {
|
||||
return true
|
||||
} else if r == '+' || r == '/' {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d utf7Decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
|
||||
var implicit bool
|
||||
var tmp int
|
||||
|
||||
nd, n := len(dst), len(src)
|
||||
if n == 0 && !atEOF {
|
||||
return 0, 0, transform.ErrShortSrc
|
||||
}
|
||||
for ; nSrc < n; nSrc++ {
|
||||
if nDst >= nd {
|
||||
return nDst, nSrc, transform.ErrShortDst
|
||||
}
|
||||
if c := src[nSrc]; ((c < u7min || c > u7max) &&
|
||||
c != '\t' && c != '\r' && c != '\n') ||
|
||||
c == '~' || c == '\\' {
|
||||
return nDst, nSrc, ErrBadUTF7 // Illegal code point in ASCII mode.
|
||||
} else if c != '+' {
|
||||
dst[nDst] = c // Character is self-representing.
|
||||
nDst++
|
||||
continue
|
||||
}
|
||||
// Found '+'.
|
||||
start := nSrc + 1
|
||||
tmp = nSrc // nSrc still points to '+', tmp points to the end of BASE64.
|
||||
|
||||
// Find the end of the Base64 or "+-" segment.
|
||||
implicit = false
|
||||
for tmp++; tmp < n && src[tmp] != '-'; tmp++ {
|
||||
if !isModifiedBase64(src[tmp]) {
|
||||
if tmp == start {
|
||||
return nDst, tmp, ErrBadUTF7 // '+' next char must modified base64.
|
||||
}
|
||||
// Implicit shift back to ASCII, no need for '-' character.
|
||||
implicit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmp == start {
|
||||
if tmp == n {
|
||||
// Did not find '-' sign and '+' is the last character.
|
||||
// Total nSrc does not include '+'.
|
||||
if atEOF {
|
||||
return nDst, nSrc, ErrBadUTF7 // '+' can not be at the end.
|
||||
}
|
||||
// '+' can not be at the end, the source is too short.
|
||||
return nDst, nSrc, transform.ErrShortSrc
|
||||
}
|
||||
dst[nDst] = '+' // Escape sequence "+-".
|
||||
nDst++
|
||||
} else if tmp == n && !atEOF {
|
||||
// No EOF found, the source is too short.
|
||||
return nDst, nSrc, transform.ErrShortSrc
|
||||
} else if b := utf7dec(src[start:tmp]); len(b) > 0 {
|
||||
if len(b)+nDst > nd {
|
||||
// Need more space in dst for the decoded modified BASE64 unicode.
|
||||
// Total nSrc does not include '+'.
|
||||
return nDst, nSrc, transform.ErrShortDst
|
||||
}
|
||||
copy(dst[nDst:], b) // Control or non-ASCII code points in Base64.
|
||||
nDst += len(b)
|
||||
if implicit {
|
||||
if nDst >= nd {
|
||||
return nDst, tmp, transform.ErrShortDst
|
||||
}
|
||||
dst[nDst] = src[tmp] // Implicit shift.
|
||||
nDst++
|
||||
}
|
||||
if tmp == n {
|
||||
return nDst, tmp, nil
|
||||
}
|
||||
} else {
|
||||
return nDst, nSrc, ErrBadUTF7 // Bad encoding.
|
||||
}
|
||||
nSrc = tmp
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// utf7dec extracts UTF-16-BE bytes from Base64 data and converts them to UTF-8.
|
||||
// A nil slice is returned if the encoding is invalid.
|
||||
func utf7dec(b64 []byte) []byte {
|
||||
var b []byte
|
||||
|
||||
// Allocate a single block of memory large enough to store the Base64 data
|
||||
// (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes.
|
||||
// Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence,
|
||||
// double the space allocation for UTF-8.
|
||||
if n := len(b64); b64[n-1] == '=' {
|
||||
return nil
|
||||
} else if n&3 == 0 {
|
||||
b = make([]byte, u7enc.DecodedLen(n)*3)
|
||||
} else {
|
||||
n += 4 - n&3
|
||||
b = make([]byte, n+u7enc.DecodedLen(n)*3)
|
||||
copy(b[copy(b, b64):n], []byte("=="))
|
||||
b64, b = b[:n], b[n:]
|
||||
}
|
||||
|
||||
// Decode Base64 into the first 1/3rd of b.
|
||||
n, err := u7enc.Decode(b, b64)
|
||||
if err != nil || n&1 == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode UTF-16-BE into the remaining 2/3rds of b.
|
||||
b, s := b[:n], b[n:]
|
||||
j := 0
|
||||
for i := 0; i < n; i += 2 {
|
||||
r := rune(b[i])<<8 | rune(b[i+1])
|
||||
if utf16.IsSurrogate(r) {
|
||||
if i += 2; i == n {
|
||||
return nil
|
||||
}
|
||||
r2 := rune(b[i])<<8 | rune(b[i+1])
|
||||
if r = utf16.DecodeRune(r, r2); r == uRepl {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
j += utf8.EncodeRune(s[j:], r)
|
||||
}
|
||||
return s[:j]
|
||||
}
|
||||
136
pkg/parallel/parallel.go
Normal file
136
pkg/parallel/parallel.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2020 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 parallel
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parallelJob is to be used for passing items between input, worker and
|
||||
// collector. `idx` is there to know the original order.
|
||||
type parallelJob struct {
|
||||
idx int
|
||||
value interface{}
|
||||
}
|
||||
|
||||
// RunParallel starts `workers` number of workers and feeds them with `input` data.
|
||||
// Each worker calls `process`. Processed data is collected in the same order as
|
||||
// the input and is passed in order to the `collect` callback. If an error
|
||||
// occurs, the execution is stopped and the error returned.
|
||||
// runParallel blocks until everything is done.
|
||||
func RunParallel( //nolint[funlen]
|
||||
workers int,
|
||||
input []interface{},
|
||||
process func(interface{}) (interface{}, error),
|
||||
collect func(int, interface{}) error,
|
||||
) (resultError error) {
|
||||
wgProcess := &sync.WaitGroup{}
|
||||
wgCollect := &sync.WaitGroup{}
|
||||
|
||||
// Optimise by not executing the code at all if there is no input
|
||||
// or run less workers than requested if there are few inputs.
|
||||
inputLen := len(input)
|
||||
if inputLen == 0 {
|
||||
return nil
|
||||
}
|
||||
if inputLen < workers {
|
||||
workers = inputLen
|
||||
}
|
||||
|
||||
inputChan := make(chan *parallelJob)
|
||||
outputChan := make(chan *parallelJob)
|
||||
|
||||
orderedCollectLock := &sync.Mutex{}
|
||||
orderedCollect := make(map[int]interface{})
|
||||
|
||||
// Feed input channel used by workers with input data with index for ordering.
|
||||
go func() {
|
||||
defer close(inputChan)
|
||||
for idx, item := range input {
|
||||
if resultError != nil {
|
||||
break
|
||||
}
|
||||
inputChan <- ¶llelJob{idx, item}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start workers and process all the inputs.
|
||||
wgProcess.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wgProcess.Done()
|
||||
for item := range inputChan {
|
||||
if output, err := process(item.value); err != nil {
|
||||
resultError = err
|
||||
break
|
||||
} else {
|
||||
outputChan <- ¶llelJob{item.idx, output}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect data into map with the original position in the array.
|
||||
wgCollect.Add(1)
|
||||
go func() {
|
||||
defer wgCollect.Done()
|
||||
for output := range outputChan {
|
||||
orderedCollectLock.Lock()
|
||||
orderedCollect[output.idx] = output.value
|
||||
orderedCollectLock.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Collect data in the same order as in the input array.
|
||||
wgCollect.Add(1)
|
||||
go func() {
|
||||
defer wgCollect.Done()
|
||||
idx := 0
|
||||
for {
|
||||
if idx >= inputLen || resultError != nil {
|
||||
break
|
||||
}
|
||||
orderedCollectLock.Lock()
|
||||
value, ok := orderedCollect[idx]
|
||||
if ok {
|
||||
if err := collect(idx, value); err != nil {
|
||||
resultError = err
|
||||
}
|
||||
delete(orderedCollect, idx)
|
||||
idx++
|
||||
}
|
||||
orderedCollectLock.Unlock()
|
||||
if !ok {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// When input channel is closed, all workers will finish. We need to wait
|
||||
// for all of them and close the output channel only once.
|
||||
wgProcess.Wait()
|
||||
close(outputChan)
|
||||
|
||||
// When workers are done, the last job is to finish collecting data. First
|
||||
// collector is finished when output channel is closed and the second one
|
||||
// when all items are passed to `collect` in the order or after an error.
|
||||
wgCollect.Wait()
|
||||
|
||||
return resultError
|
||||
}
|
||||
131
pkg/parallel/parallel_test.go
Normal file
131
pkg/parallel/parallel_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2020 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 parallel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
r "github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// nolint[gochecknoglobals]
|
||||
var (
|
||||
testInput = []interface{}{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
wantOutput = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
testProcessSleep = 100 // ms
|
||||
runParallelTimeOverhead = 100 // ms
|
||||
)
|
||||
|
||||
func TestParallel(t *testing.T) {
|
||||
workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
|
||||
for _, workers := range workersTests {
|
||||
workers := workers
|
||||
t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) {
|
||||
collected := make([]int, 0)
|
||||
collect := func(idx int, value interface{}) error {
|
||||
collected = append(collected, value.(int))
|
||||
return nil
|
||||
}
|
||||
|
||||
tstart := time.Now()
|
||||
err := RunParallel(workers, testInput, processSleep, collect)
|
||||
duration := time.Since(tstart)
|
||||
|
||||
r.Nil(t, err)
|
||||
r.Equal(t, wantOutput, collected) // Check the order is always kept.
|
||||
|
||||
wantMinDuration := int(math.Ceil(float64(len(testInput))/float64(workers))) * testProcessSleep
|
||||
wantMaxDuration := wantMinDuration + runParallelTimeOverhead
|
||||
r.True(t, duration.Nanoseconds() > int64(wantMinDuration*1000000), "Duration too short: %v (expected: %v)", duration, wantMinDuration)
|
||||
r.True(t, duration.Nanoseconds() < int64(wantMaxDuration*1000000), "Duration too long: %v (expected: %v)", duration, wantMaxDuration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelEmptyInput(t *testing.T) {
|
||||
workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
|
||||
for _, workers := range workersTests {
|
||||
workers := workers
|
||||
t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) {
|
||||
err := RunParallel(workers, []interface{}{}, processSleep, collectNil)
|
||||
r.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelErrorInProcess(t *testing.T) {
|
||||
workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
|
||||
for _, workers := range workersTests {
|
||||
workers := workers
|
||||
t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) {
|
||||
var lastCollected int
|
||||
process := func(value interface{}) (interface{}, error) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if value.(int) == 5 {
|
||||
return nil, errors.New("Error")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
collect := func(idx int, value interface{}) error {
|
||||
lastCollected = value.(int)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := RunParallel(workers, testInput, process, collect)
|
||||
r.EqualError(t, err, "Error")
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
r.True(t, lastCollected < 5, "Last collected cannot be higher that 5, got: %d", lastCollected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelErrorInCollect(t *testing.T) {
|
||||
workersTests := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
|
||||
for _, workers := range workersTests {
|
||||
workers := workers
|
||||
t.Run(fmt.Sprintf("%d", workers), func(t *testing.T) {
|
||||
collect := func(idx int, value interface{}) error {
|
||||
if value.(int) == 5 {
|
||||
return errors.New("Error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := RunParallel(workers, testInput, processSleep, collect)
|
||||
r.EqualError(t, err, "Error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func processSleep(value interface{}) (interface{}, error) {
|
||||
time.Sleep(time.Duration(testProcessSleep) * time.Millisecond)
|
||||
return value.(int), nil
|
||||
}
|
||||
|
||||
func collectNil(idx int, value interface{}) error {
|
||||
return nil
|
||||
}
|
||||
309
pkg/pmapi/Changelog.md
Normal file
309
pkg/pmapi/Changelog.md
Normal file
@ -0,0 +1,309 @@
|
||||
# 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)
|
||||
19
pkg/pmapi/Makefile
Normal file
19
pkg/pmapi/Makefile
Normal file
@ -0,0 +1,19 @@
|
||||
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} ./...
|
||||
204
pkg/pmapi/addresses.go
Normal file
204
pkg/pmapi/addresses.go
Normal file
@ -0,0 +1,204 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"strings"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
)
|
||||
|
||||
// Address statuses.
|
||||
const (
|
||||
DisabledAddress = iota
|
||||
EnabledAddress
|
||||
)
|
||||
|
||||
// Address receive values.
|
||||
const (
|
||||
CannotReceive = iota
|
||||
CanReceive
|
||||
)
|
||||
|
||||
// Address HasKeys values.
|
||||
const (
|
||||
MissingKeys = iota
|
||||
KeysPresent
|
||||
)
|
||||
|
||||
// Address types.
|
||||
const (
|
||||
_ = iota // Skip first.
|
||||
OriginalAddress
|
||||
AliasAddress
|
||||
CustomAddress
|
||||
PremiumAddress
|
||||
)
|
||||
|
||||
// Address Send values.
|
||||
const (
|
||||
NoSendAddress = iota
|
||||
MainSendAddress
|
||||
SecondarySendAddress
|
||||
)
|
||||
|
||||
// Address represents a user's address.
|
||||
type Address struct {
|
||||
ID string
|
||||
DomainID string
|
||||
Email string
|
||||
Send int
|
||||
Receive int
|
||||
Status int
|
||||
Order int `json:",omitempty"`
|
||||
Type int
|
||||
DisplayName string
|
||||
Signature string
|
||||
MemberID string `json:",omitempty"`
|
||||
MemberName string `json:",omitempty"`
|
||||
|
||||
HasKeys int
|
||||
Keys PMKeys
|
||||
}
|
||||
|
||||
// AddressList is a list of addresses.
|
||||
type AddressList []*Address
|
||||
|
||||
type AddressesRes struct {
|
||||
Res
|
||||
Addresses AddressList
|
||||
}
|
||||
|
||||
// KeyRing returns the (possibly unlocked) PMKeys KeyRing.
|
||||
func (a *Address) KeyRing() *pmcrypto.KeyRing {
|
||||
return a.Keys.KeyRing
|
||||
}
|
||||
|
||||
// ByID returns an address by id. Returns nil if no address is found.
|
||||
func (l AddressList) ByID(id string) *Address {
|
||||
for _, addr := range l {
|
||||
if addr.ID == id {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l AddressList) ActiveEmails() (addresses []string) {
|
||||
for _, a := range l {
|
||||
if a.Receive == CanReceive {
|
||||
addresses = append(addresses, a.Email)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Main gets the main address.
|
||||
func (l AddressList) Main() *Address {
|
||||
for _, addr := range l {
|
||||
if addr.Order == 1 {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return l[0] // Should not happen.
|
||||
}
|
||||
|
||||
// ByEmail gets an address by email. Returns nil if no address is found.
|
||||
func (l AddressList) ByEmail(email string) *Address {
|
||||
email = SanitizeEmail(email)
|
||||
for _, addr := range l {
|
||||
if strings.EqualFold(addr.Email, email) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizeEmail(email string) string {
|
||||
splitAt := strings.Split(email, "@")
|
||||
if len(splitAt) != 2 {
|
||||
return email
|
||||
}
|
||||
splitPlus := strings.Split(splitAt[0], "+")
|
||||
email = splitPlus[0] + "@" + splitAt[1]
|
||||
return email
|
||||
}
|
||||
|
||||
func ConstructAddress(headerEmail string, addressEmail string) string {
|
||||
splitAtHeader := strings.Split(headerEmail, "@")
|
||||
if len(splitAtHeader) != 2 {
|
||||
return addressEmail
|
||||
}
|
||||
|
||||
splitPlus := strings.Split(splitAtHeader[0], "+")
|
||||
if len(splitPlus) != 2 {
|
||||
return addressEmail
|
||||
}
|
||||
|
||||
splitAtAddress := strings.Split(addressEmail, "@")
|
||||
if len(splitAtAddress) != 2 {
|
||||
return addressEmail
|
||||
}
|
||||
|
||||
return splitAtAddress[0] + "+" + splitPlus[1] + "@" + splitAtAddress[1]
|
||||
}
|
||||
|
||||
// GetAddresses requests all of current user addresses (without pagination).
|
||||
func (c *Client) GetAddresses() (addresses AddressList, err error) {
|
||||
req, err := NewRequest("GET", "/addresses", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res AddressesRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return res.Addresses, res.Err()
|
||||
}
|
||||
|
||||
func (c *Client) Addresses() AddressList {
|
||||
return c.addresses
|
||||
}
|
||||
|
||||
// UnlockAddresses unlocks all keys for all addresses of current user.
|
||||
func (c *Client) UnlockAddresses(passphrase []byte) (err error) {
|
||||
for _, a := range c.addresses {
|
||||
if a.HasKeys == MissingKeys {
|
||||
continue
|
||||
}
|
||||
|
||||
// Unlock the address token using the UserKey, use the unlocked token to unlock the keyring.
|
||||
if err = a.Keys.unlockKeyRing(c.kr, passphrase, c.keyLocker); err != nil {
|
||||
err = fmt.Errorf("pmapi: cannot unlock private key of address %v: %v", a.Email, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) KeyRingForAddressID(addrID string) *pmcrypto.KeyRing {
|
||||
addr := c.addresses.ByID(addrID)
|
||||
if addr == nil {
|
||||
addr = c.addresses.Main()
|
||||
}
|
||||
return addr.KeyRing()
|
||||
}
|
||||
90
pkg/pmapi/addresses_test.go
Normal file
90
pkg/pmapi/addresses_test.go
Normal file
@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testAddressList = AddressList{
|
||||
&Address{
|
||||
ID: "1",
|
||||
Email: "root@nsa.gov",
|
||||
Send: SecondarySendAddress,
|
||||
Status: EnabledAddress,
|
||||
Order: 2,
|
||||
},
|
||||
&Address{
|
||||
ID: "2",
|
||||
Email: "root@gchq.gov.uk",
|
||||
Send: MainSendAddress,
|
||||
Status: EnabledAddress,
|
||||
Order: 1,
|
||||
},
|
||||
&Address{
|
||||
ID: "3",
|
||||
Email: "root@protonmail.com",
|
||||
Send: NoSendAddress,
|
||||
Status: DisabledAddress,
|
||||
Order: 3,
|
||||
},
|
||||
}
|
||||
|
||||
func routeGetAddresses(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(tb, checkMethodAndPath(r, "GET", "/addresses"))
|
||||
Ok(tb, isAuthReq(r, testUID, testAccessToken))
|
||||
return "addresses/get_response.json"
|
||||
}
|
||||
|
||||
func routeGetSalts(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(tb, checkMethodAndPath(r, "GET", "/keys/salts"))
|
||||
Ok(tb, isAuthReq(r, testUID, testAccessToken))
|
||||
return "keys/salts/get_response.json"
|
||||
}
|
||||
|
||||
func TestAddressList(t *testing.T) {
|
||||
input := "1"
|
||||
addr := testAddressList.ByID(input)
|
||||
if addr != testAddressList[0] {
|
||||
t.Errorf("ById(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[0], addr)
|
||||
}
|
||||
|
||||
input = "42"
|
||||
addr = testAddressList.ByID(input)
|
||||
if addr != nil {
|
||||
t.Errorf("ById expected nil for %s but have : %v\n", input, addr)
|
||||
}
|
||||
|
||||
input = "root@protonmail.com"
|
||||
addr = testAddressList.ByEmail(input)
|
||||
if addr != testAddressList[2] {
|
||||
t.Errorf("ByEmail(%s) expected:\n%v\n but have:\n%v\n", input, testAddressList[2], addr)
|
||||
}
|
||||
|
||||
input = "idontexist@protonmail.com"
|
||||
addr = testAddressList.ByEmail(input)
|
||||
if addr != nil {
|
||||
t.Errorf("ByEmail expected nil for %s but have : %v\n", input, addr)
|
||||
}
|
||||
|
||||
addr = testAddressList.Main()
|
||||
if addr != testAddressList[1] {
|
||||
t.Errorf("Main() expected:\n%v\n but have:\n%v\n", testAddressList[1], addr)
|
||||
}
|
||||
}
|
||||
264
pkg/pmapi/attachments.go
Normal file
264
pkg/pmapi/attachments.go
Normal file
@ -0,0 +1,264 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
)
|
||||
|
||||
type header textproto.MIMEHeader
|
||||
|
||||
type rawHeader map[string]json.RawMessage
|
||||
|
||||
func (h *header) UnmarshalJSON(b []byte) error {
|
||||
if *h == nil {
|
||||
*h = make(header)
|
||||
}
|
||||
|
||||
raw := make(rawHeader)
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, v := range raw {
|
||||
// Most headers are string because they have only one value.
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err == nil {
|
||||
textproto.MIMEHeader(*h).Set(k, s)
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's not a string, it must be an array of strings.
|
||||
var a []string
|
||||
if err := json.Unmarshal(v, &a); err != nil {
|
||||
return fmt.Errorf("pmapi: attachment header field is neither a string nor an array of strings: %v", err)
|
||||
}
|
||||
for _, vv := range a {
|
||||
textproto.MIMEHeader(*h).Add(k, vv)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attachment represents a message attachment.
|
||||
type Attachment struct {
|
||||
ID string `json:",omitempty"`
|
||||
MessageID string `json:",omitempty"` // msg v3 ???
|
||||
Name string `json:",omitempty"`
|
||||
Size int64 `json:",omitempty"`
|
||||
MIMEType string `json:",omitempty"`
|
||||
ContentID string `json:",omitempty"`
|
||||
KeyPackets string `json:",omitempty"`
|
||||
Signature string `json:",omitempty"`
|
||||
|
||||
Header textproto.MIMEHeader `json:"-"`
|
||||
}
|
||||
|
||||
// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
|
||||
type attachment Attachment
|
||||
|
||||
type rawAttachment struct {
|
||||
attachment
|
||||
|
||||
Header header `json:"Headers,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Attachment) MarshalJSON() ([]byte, error) {
|
||||
var raw rawAttachment
|
||||
raw.attachment = attachment(*a)
|
||||
|
||||
if a.Header != nil {
|
||||
raw.Header = header(a.Header)
|
||||
}
|
||||
|
||||
return json.Marshal(&raw)
|
||||
}
|
||||
|
||||
func (a *Attachment) UnmarshalJSON(b []byte) error {
|
||||
var raw rawAttachment
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*a = Attachment(raw.attachment)
|
||||
|
||||
if raw.Header != nil {
|
||||
a.Header = textproto.MIMEHeader(raw.Header)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts this attachment's data from r using the keys from kr.
|
||||
func (a *Attachment) Decrypt(r io.Reader, kr *pmcrypto.KeyRing) (decrypted io.Reader, err error) {
|
||||
keyPackets, err := base64.StdEncoding.DecodeString(a.KeyPackets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return decryptAttachment(kr, keyPackets, r)
|
||||
}
|
||||
|
||||
// Encrypt encrypts an attachment.
|
||||
func (a *Attachment) Encrypt(kr *pmcrypto.KeyRing, att io.Reader) (encrypted io.Reader, err error) {
|
||||
return encryptAttachment(kr, att, a.Name)
|
||||
}
|
||||
|
||||
func (a *Attachment) DetachedSign(kr *pmcrypto.KeyRing, att io.Reader) (signed io.Reader, err error) {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if err = w.WriteField("MessageID", att.MessageID); err != nil {
|
||||
return
|
||||
}
|
||||
if err = w.WriteField("MIMEType", att.MIMEType); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = w.WriteField("ContentID", att.ContentID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// And send attachment data.
|
||||
ff, err := w.CreateFormFile("DataPacket", "DataPacket.pgp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = io.Copy(ff, r); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// And send attachment data.
|
||||
sigff, err := w.CreateFormFile("Signature", "Signature.pgp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = io.Copy(sigff, sig); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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) (created *Attachment, err error) {
|
||||
req, w, err := NewMultipartRequest("POST", "/attachments")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
if err = <-done; err != nil {
|
||||
return
|
||||
}
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
created = res.Attachment
|
||||
return
|
||||
}
|
||||
|
||||
type UpdateAttachmentSignatureReq struct {
|
||||
Signature string
|
||||
}
|
||||
|
||||
func (c *Client) UpdateAttachmentSignature(attachmentID, signature string) (err error) {
|
||||
updateReq := &UpdateAttachmentSignatureReq{signature}
|
||||
req, err := NewJSONRequest("PUT", "/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 := NewRequest("DELETE", "/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 := NewRequest("GET", "/attachments/"+id, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := c.Do(req, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
att = res.Body
|
||||
return
|
||||
}
|
||||
222
pkg/pmapi/attachments_test.go
Normal file
222
pkg/pmapi/attachments_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2020 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/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var testAttachment = &Attachment{
|
||||
ID: "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
|
||||
Name: "croutonmail.txt",
|
||||
Size: 77,
|
||||
MIMEType: "text/plain",
|
||||
KeyPackets: "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
|
||||
Header: textproto.MIMEHeader{
|
||||
"Content-Description": {"You'll never believe what's in this text file"},
|
||||
"X-Mailer": {"Microsoft Outlook 15.0", "Microsoft Live Mail 42.0"},
|
||||
},
|
||||
MessageID: "h3CD-DT7rLoAw1vmpcajvIPAl-wwDfXR2MHtWID3wuQURDBKTiGUAwd6E2WBbS44QQKeXImW-axm6X0hAfcVCA==",
|
||||
}
|
||||
|
||||
const testAttachmentJSON = `{
|
||||
"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw==",
|
||||
"Name": "croutonmail.txt",
|
||||
"Size": 77,
|
||||
"MIMEType": "text/plain",
|
||||
"KeyPackets": "wcBMA0fcZ7XLgmf2AQgAiRsOlnm1kSB4/lr7tYe6pBsRGn10GqwUhrwU5PMKOHdCgnO12jO3y3CzP0Yl/jGhAYja9wLDqH8X0sk3tY32u4Sb1Qe5IuzggAiCa4dwOJj5gEFMTHMzjIMPHR7A70XqUxMhmILye8V4KRm/j4c1sxbzA1rM3lYBumQuB5l/ck0Kgt4ZqxHVXHK5Q1l65FHhSXRj8qnunasHa30TYNzP8nmBA8BinnJxpiQ7FGc2umnUhgkFtjm5ixu9vyjr9ukwDTbwAXXfmY+o7tK7kqIXJcmTL6k2UeC6Mz1AagQtRCRtU+bv/3zGojq/trZo9lom3naIeQYa36Ketmcpj2Qwjg==",
|
||||
"Headers": {
|
||||
"content-description": "You'll never believe what's in this text file",
|
||||
"x-mailer": [
|
||||
"Microsoft Outlook 15.0",
|
||||
"Microsoft Live Mail 42.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const testAttachmentCleartext = `cc,
|
||||
dille.
|
||||
`
|
||||
|
||||
const testAttachmentEncrypted = `wcBMA0fcZ7XLgmf2AQf/cHhfDRM9zlIuBi+h2W6DKjbbyIHMkgF6ER3JEvn/tSruUH8KTGt0N7Z+a80FFMCuXn1Y1I/nW7MVrNhGuJZAF4OymD8ugvuoAMIQX0eCYEpPXzRIWJBZg82AuowmFMsv8Dgvq4bTZq4cttI3CZcxKUNXuAearmNpmgplUKWj5USmRXK4iGB3VFGjidXkxbElrP4fD5A/rfEZ5aJgCsegqcXxX3MEjWXi9pFzgd/9phOvl1ZFm9U9hNoVAW3QsgmVeihnKaDZUyf2Qsigij21QKAUxw9U3y89eTUIqZAcmIgqeDujA3RWBgJwjtY/lOyhEmkf3AWKzehvf1xtJmCWDtJLAekuH+JfAtTQfMs5nf4zYtMahGbMkwy3Uz/jeEMYdzWY5WvshkbwvaxpqFC+11cqMLBvxik39i1xf+RORZF/91jGMCL9Z9dRMcgB`
|
||||
|
||||
const testCreateAttachmentBody = `{
|
||||
"Code": 1000,
|
||||
"Attachment": {"ID": "y6uKIlc2HdoHPAwPSrvf7dXoZNMYvBgxshYUN67cY5DJjL2O8NYewuvGHcYvCfd8LpEoAI_GdymO0Jr0mHlsEw=="}
|
||||
}`
|
||||
|
||||
const testDeleteAttachmentBody = `{
|
||||
"Code": 1000
|
||||
}`
|
||||
|
||||
func TestAttachment_UnmarshalJSON(t *testing.T) {
|
||||
att := new(Attachment)
|
||||
if err := json.Unmarshal([]byte(testAttachmentJSON), att); err != nil {
|
||||
t.Fatal("Expected no error while unmarshaling JSON, got:", err)
|
||||
}
|
||||
|
||||
att.MessageID = testAttachment.MessageID // This isn't in the JSON object
|
||||
|
||||
if !reflect.DeepEqual(testAttachment, att) {
|
||||
t.Errorf("Invalid attachment: expected %+v but got %+v", testAttachment, att)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/attachments"))
|
||||
|
||||
contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing request content type, got:", err)
|
||||
}
|
||||
if contentType != "multipart/form-data" {
|
||||
t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||
form, err := mr.ReadForm(10 * 1024)
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing request form, got:", err)
|
||||
}
|
||||
defer Ok(t, form.RemoveAll())
|
||||
|
||||
if form.Value["Filename"][0] != testAttachment.Name {
|
||||
t.Errorf("Invalid attachment filename: expected %v but got %v", testAttachment.Name, form.Value["Filename"][0])
|
||||
}
|
||||
if form.Value["MessageID"][0] != testAttachment.MessageID {
|
||||
t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MessageID, form.Value["MessageID"][0])
|
||||
}
|
||||
if form.Value["MIMEType"][0] != testAttachment.MIMEType {
|
||||
t.Errorf("Invalid attachment message id: expected %v but got %v", testAttachment.MIMEType, form.Value["MIMEType"][0])
|
||||
}
|
||||
|
||||
dataFile, err := form.File["DataPacket"][0].Open()
|
||||
if err != nil {
|
||||
t.Error("Expected no error while opening packets file, got:", err)
|
||||
}
|
||||
defer Ok(t, dataFile.Close())
|
||||
|
||||
b, err := ioutil.ReadAll(dataFile)
|
||||
if err != nil {
|
||||
t.Error("Expected no error while reading packets file, got:", err)
|
||||
}
|
||||
if string(b) != testAttachmentCleartext {
|
||||
t.Errorf("Invalid attachment packets: expected %v but got %v", testAttachment.KeyPackets, string(b))
|
||||
}
|
||||
|
||||
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(""))
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while creating attachment, got:", err)
|
||||
}
|
||||
|
||||
if created.ID != testAttachment.ID {
|
||||
t.Errorf("Invalid attachment id: expected %v but got %v", testAttachment.ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DeleteAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "DELETE", "/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) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/attachments/"+testAttachment.ID))
|
||||
|
||||
fmt.Fprint(w, testAttachmentCleartext)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
r, err := c.GetAttachment(testAttachment.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting attachment, got:", err)
|
||||
}
|
||||
defer r.Close() //nolint[errcheck]
|
||||
|
||||
// In reality, r contains encrypted data
|
||||
b, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while reading attachment, got:", err)
|
||||
}
|
||||
|
||||
if string(b) != testAttachmentCleartext {
|
||||
t.Errorf("Invalid attachment data: expected %q but got %q", testAttachmentCleartext, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachment_Encrypt(t *testing.T) {
|
||||
data := bytes.NewBufferString(testAttachmentCleartext)
|
||||
r, err := testAttachment.Encrypt(testPublicKeyRing, data)
|
||||
assert.Nil(t, err)
|
||||
b, err := ioutil.ReadAll(r)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Result is always different, so the best way is to test it by decrypting again.
|
||||
// Another test for decrypting will help us to be sure it's working.
|
||||
dataEnc := bytes.NewBuffer(b)
|
||||
decryptAndCheck(t, dataEnc)
|
||||
}
|
||||
|
||||
func TestAttachment_Decrypt(t *testing.T) {
|
||||
dataBytes, _ := base64.StdEncoding.DecodeString(testAttachmentEncrypted)
|
||||
dataReader := bytes.NewBuffer(dataBytes)
|
||||
decryptAndCheck(t, dataReader)
|
||||
}
|
||||
|
||||
func decryptAndCheck(t *testing.T, data io.Reader) {
|
||||
r, err := testAttachment.Decrypt(data, testPrivateKeyRing)
|
||||
assert.Nil(t, err)
|
||||
b, err := ioutil.ReadAll(r)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testAttachmentCleartext, string(b))
|
||||
}
|
||||
506
pkg/pmapi/auth.go
Normal file
506
pkg/pmapi/auth.go
Normal file
@ -0,0 +1,506 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
)
|
||||
|
||||
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
|
||||
Scope string
|
||||
uid string // Read from AuthRes.
|
||||
RefreshToken string
|
||||
KeySalt string
|
||||
EventID string
|
||||
PasswordMode int
|
||||
TwoFA *TwoFactorInfo `json:"2FA,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Auth) UID() string {
|
||||
return s.uid
|
||||
}
|
||||
|
||||
func (s *Auth) HasTwoFactor() bool {
|
||||
if s.TwoFA == nil {
|
||||
return false
|
||||
}
|
||||
return s.TwoFA.hasTwoFactor()
|
||||
}
|
||||
|
||||
func (s *Auth) HasMailboxPassword() bool {
|
||||
return s.PasswordMode == 2
|
||||
}
|
||||
|
||||
func (s *Auth) hasFullScope() bool {
|
||||
return strings.Contains(s.Scope, "full")
|
||||
}
|
||||
|
||||
type AuthRes struct {
|
||||
Res
|
||||
Auth
|
||||
|
||||
AccessToken string
|
||||
TokenType string
|
||||
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 Auth2FA struct {
|
||||
Scope string
|
||||
}
|
||||
|
||||
type Auth2FARes struct {
|
||||
Res
|
||||
|
||||
Scope string
|
||||
}
|
||||
|
||||
func (res *Auth2FARes) getAuth2FA() *Auth2FA {
|
||||
return &Auth2FA{
|
||||
Scope: res.Scope,
|
||||
}
|
||||
}
|
||||
|
||||
type AuthRefreshReq struct {
|
||||
ResponseType string
|
||||
GrantType string
|
||||
RefreshToken string
|
||||
UID string
|
||||
RedirectURI string
|
||||
State string
|
||||
}
|
||||
|
||||
// SetAuths sets auths channel.
|
||||
func (c *Client) SetAuths(auths chan<- *Auth) {
|
||||
c.auths = auths
|
||||
}
|
||||
|
||||
// AuthInfo gets authentication info for a user.
|
||||
func (c *Client) AuthInfo(username string) (info *AuthInfo, err error) {
|
||||
infoReq := &AuthInfoReq{
|
||||
Username: username,
|
||||
}
|
||||
|
||||
req, err := 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 := 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.uid = auth.UID()
|
||||
c.accessToken = auth.accessToken
|
||||
|
||||
if c.auths != nil {
|
||||
c.auths <- auth
|
||||
}
|
||||
|
||||
if c.tokenManager != nil {
|
||||
c.tokenManager.SetToken(c.userID, c.uid+":"+auth.RefreshToken)
|
||||
c.log.Info("Set token from auth " + c.uid + ":" + auth.RefreshToken)
|
||||
}
|
||||
|
||||
// Auth has to be fully unlocked to get key salt. During `Auth` it can happen
|
||||
// only to accounts without 2FA. For 2FA accounts, it's done in `Auth2FA`.
|
||||
if auth.hasFullScope() {
|
||||
err = c.setKeySaltToAuth(auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
|
||||
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) (*Auth2FA, error) {
|
||||
auth2FAReq := &Auth2FAReq{
|
||||
TwoFactorCode: twoFactorCode,
|
||||
}
|
||||
|
||||
req, err := NewJSONRequest("POST", "/auth/2fa", auth2FAReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var auth2FARes Auth2FARes
|
||||
if err := c.DoJSON(req, &auth2FARes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := auth2FARes.Err(); err != nil {
|
||||
switch auth2FARes.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return nil, ErrBad2FACode
|
||||
case http.StatusUnprocessableEntity:
|
||||
return nil, ErrBad2FACodeTryAgain
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.setKeySaltToAuth(auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return auth2FARes.getAuth2FA(), nil
|
||||
}
|
||||
|
||||
func (c *Client) setKeySaltToAuth(auth *Auth) error {
|
||||
// KeySalt already set up, no need to do it again.
|
||||
if auth.KeySalt != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := c.CurrentUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
salts, err := c.GetKeySalts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range salts {
|
||||
if s.ID == user.KeyRing().FirstKeyID {
|
||||
auth.KeySalt = s.KeySalt
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock decrypts the key ring.
|
||||
// If the password is invalid, IsUnlockError(err) will return true.
|
||||
func (c *Client) Unlock(password string) (kr *pmcrypto.KeyRing, err error) {
|
||||
if _, err = c.CurrentUser(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.keyLocker.Lock()
|
||||
defer c.keyLocker.Unlock()
|
||||
|
||||
kr = c.user.KeyRing()
|
||||
if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(kr, []byte(password)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.kr = kr
|
||||
return kr, err
|
||||
}
|
||||
|
||||
// AuthRefresh will refresh an expired access token.
|
||||
func (c *Client) AuthRefresh(uidAndRefreshToken string) (auth *Auth, err error) {
|
||||
// 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).
|
||||
if c.tokenManager != nil {
|
||||
currentAccessToken := c.tokenManager.GetToken(c.userID)
|
||||
if currentAccessToken == "" {
|
||||
c.log.WithField("token", uidAndRefreshToken).
|
||||
Info("Currently have no access token, setting given one")
|
||||
c.tokenManager.SetToken(c.userID, uidAndRefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
split := strings.Split(uidAndRefreshToken, ":")
|
||||
if len(split) != 2 {
|
||||
err = ErrInvalidToken
|
||||
return
|
||||
}
|
||||
|
||||
refreshReq := &AuthRefreshReq{
|
||||
ResponseType: "token",
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: split[1],
|
||||
UID: split[0],
|
||||
RedirectURI: "https://protonmail.ch",
|
||||
State: "random_string",
|
||||
}
|
||||
|
||||
// UID must be set for `x-pm-uid` header field, see backend-communication#11
|
||||
c.uid = split[0]
|
||||
|
||||
req, err := 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()
|
||||
// UID should never change after auth, see backend-communication#11
|
||||
auth.uid = c.uid
|
||||
if c.auths != nil {
|
||||
c.auths <- auth
|
||||
}
|
||||
|
||||
c.uid = auth.UID()
|
||||
c.accessToken = auth.accessToken
|
||||
|
||||
if c.tokenManager != nil {
|
||||
c.tokenManager.SetToken(c.userID, c.uid+":"+res.RefreshToken)
|
||||
c.log.Info("Set token from auth refresh " + c.uid + ":" + res.RefreshToken)
|
||||
}
|
||||
|
||||
c.expiresAt = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
|
||||
return auth, err
|
||||
}
|
||||
|
||||
// Logout logs the current user out.
|
||||
func (c *Client) Logout() (err error) {
|
||||
req, err := NewRequest("DELETE", "/auth", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = res.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// This can trigger a deadlock! We don't want to do it if the above requests failed (GODT-154).
|
||||
// That's why it's not in the deferred statement above.
|
||||
if c.auths != nil {
|
||||
c.auths <- nil
|
||||
}
|
||||
|
||||
// This should ideally be deferred at the top of this method so that it is executed
|
||||
// regardless of what happens, but we currently don't have a way to prevent ourselves
|
||||
// from using a logged out client. So for now, it's down here, as it was in Charles release.
|
||||
// defer func() {
|
||||
c.uid = ""
|
||||
c.accessToken = ""
|
||||
c.kr = nil
|
||||
// c.addresses = nil
|
||||
c.user = nil
|
||||
if c.tokenManager != nil {
|
||||
c.tokenManager.SetToken(c.userID, "")
|
||||
}
|
||||
// }()
|
||||
|
||||
return err
|
||||
}
|
||||
366
pkg/pmapi/auth_test.go
Normal file
366
pkg/pmapi/auth_test.go
Normal file
@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/srp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
a "github.com/stretchr/testify/assert"
|
||||
r "github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var aLongTimeAgo = time.Unix(233431200, 0)
|
||||
|
||||
var testIdentity = &pmcrypto.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]
|
||||
)
|
||||
|
||||
var testAuthInfo = &AuthInfo{
|
||||
TwoFA: &TwoFactorInfo{TOTP: 1},
|
||||
|
||||
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==",
|
||||
}
|
||||
|
||||
// testAuth has default values which are adjusted in each test.
|
||||
var testAuth = &Auth{
|
||||
EventID: "NcKPtU5eMNPMrDkIMbEJrgMtC9yQ7Xc5ZBT-tB3UtV1rZ324RWfCIdBI758q0UnsfywS8CkNenIQlWLIX_dUng==",
|
||||
ExpiresIn: 86400,
|
||||
RefreshToken: "feb3159ac63fb05119bcf4480d939278aa746926",
|
||||
Scope: "full mail payments reset keys",
|
||||
|
||||
accessToken: testAccessToken,
|
||||
uid: testUID,
|
||||
}
|
||||
|
||||
var testAuth2FA = &Auth2FA{
|
||||
Scope: "full mail payments reset keys",
|
||||
}
|
||||
|
||||
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"
|
||||
},
|
||||
routeGetUsers,
|
||||
routeGetAddresses,
|
||||
routeGetSalts,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
auth, err := c.Auth(testUsername, testAPIPassword, testAuthInfo)
|
||||
r.Nil(t, err)
|
||||
|
||||
r.True(t, c.user.KeyRing().FirstKeyID != "", "Parsing First key ID issue")
|
||||
|
||||
exp := &Auth{}
|
||||
*exp = *testAuth
|
||||
exp.accessToken = testAccessToken
|
||||
exp.RefreshToken = testRefreshToken
|
||||
exp.KeySalt = "abc"
|
||||
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"
|
||||
},
|
||||
routeGetUsers,
|
||||
routeGetAddresses,
|
||||
routeGetSalts,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
auth2FA, err := c.Auth2FA(testAuth2FAReq.TwoFactorCode, testAuth)
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, testAuth2FA, auth2FA)
|
||||
}
|
||||
|
||||
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("wrong")
|
||||
a.True(t, IsUnlockError(err), "expected error, pasword is wrong")
|
||||
|
||||
_, err = c.Unlock(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("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(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)
|
||||
|
||||
exp := &Auth{}
|
||||
*exp = *testAuth
|
||||
exp.accessToken = testAccessToken
|
||||
exp.KeySalt = ""
|
||||
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.KeySalt = ""
|
||||
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
|
||||
|
||||
Ok(t, c.Logout())
|
||||
}
|
||||
|
||||
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.expiresAt = aLongTimeAgo
|
||||
c.tokenManager = NewTokenManager()
|
||||
c.tokenManager.tokenMap[c.userID] = testUID + ":" + testRefreshToken
|
||||
|
||||
req, err := NewRequest("GET", "/", nil)
|
||||
Ok(t, err)
|
||||
|
||||
res, err := c.Do(req, true)
|
||||
Ok(t, err)
|
||||
|
||||
defer Ok(t, res.Body.Close())
|
||||
}
|
||||
23
pkg/pmapi/auth_test_export.go
Normal file
23
pkg/pmapi/auth_test_export.go
Normal file
@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2020 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
|
||||
}
|
||||
217
pkg/pmapi/bugs.go
Normal file
217
pkg/pmapi/bugs.go
Normal file
@ -0,0 +1,217 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientType is required by API.
|
||||
const (
|
||||
EmailClientType = iota + 1
|
||||
VPNClientType
|
||||
)
|
||||
|
||||
type reportAtt struct {
|
||||
name, filename string
|
||||
body io.Reader
|
||||
}
|
||||
|
||||
// ReportReq stores data for report.
|
||||
type ReportReq struct {
|
||||
OS string `json:",omitempty"`
|
||||
OSVersion string `json:",omitempty"`
|
||||
Browser string `json:",omitempty"`
|
||||
BrowserVersion string `json:",omitempty"`
|
||||
BrowserExtensions string `json:",omitempty"`
|
||||
Resolution string `json:",omitempty"`
|
||||
DisplayMode string `json:",omitempty"`
|
||||
Client string `json:",omitempty"`
|
||||
ClientVersion string `json:",omitempty"`
|
||||
ClientType int `json:",omitempty"`
|
||||
Title string `json:",omitempty"`
|
||||
Description string `json:",omitempty"`
|
||||
Username string `json:",omitempty"`
|
||||
Email string `json:",omitempty"`
|
||||
Country string `json:",omitempty"`
|
||||
ISP string `json:",omitempty"`
|
||||
Debug string `json:",omitempty"`
|
||||
Attachments []reportAtt `json:",omitempty"`
|
||||
}
|
||||
|
||||
// AddAttachment to report.
|
||||
func (rep *ReportReq) 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]
|
||||
fieldData := map[string]string{
|
||||
"OS": rep.OS,
|
||||
"OSVersion": rep.OSVersion,
|
||||
"Browser": rep.Browser,
|
||||
"BrowserVersion": rep.BrowserVersion,
|
||||
"BrowserExtensions": rep.BrowserExtensions,
|
||||
"Resolution": rep.Resolution,
|
||||
"DisplayMode": rep.DisplayMode,
|
||||
"Client": rep.Client,
|
||||
"ClientVersion": rep.ClientVersion,
|
||||
"ClientType": "1",
|
||||
"Title": rep.Title,
|
||||
"Description": rep.Description,
|
||||
"Username": rep.Username,
|
||||
"Email": rep.Email,
|
||||
"Country": rep.Country,
|
||||
"ISP": rep.ISP,
|
||||
"Debug": rep.Debug,
|
||||
}
|
||||
|
||||
for field, data := range fieldData {
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if err := w.WriteField(field, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
quoteEscaper := strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
|
||||
|
||||
for _, att := range rep.Attachments {
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition",
|
||||
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
|
||||
quoteEscaper.Replace(att.name), quoteEscaper.Replace(att.filename+".zip")))
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
//h.Set("Content-Transfere-Encoding", "base64")
|
||||
attWr, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zipArch := zip.NewWriter(attWr)
|
||||
zipWr, err := zipArch.Create(att.filename)
|
||||
//b64 := base64.NewEncoder(base64.StdEncoding, zipWr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(zipWr, att.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = zipArch.Close()
|
||||
//err = b64.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Report sends request as json or multipart (if has attachment).
|
||||
func (c *Client) Report(rep ReportReq) (err error) {
|
||||
rep.Client = c.config.ClientID
|
||||
rep.ClientVersion = c.config.AppVersion
|
||||
rep.ClientType = EmailClientType
|
||||
|
||||
var req *http.Request
|
||||
var w *MultipartWriter
|
||||
if len(rep.Attachments) > 0 {
|
||||
req, w, err = NewMultipartRequest("POST", "/reports/bug")
|
||||
} else {
|
||||
req, err = 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()
|
||||
}
|
||||
|
||||
// ReportBug is old. Use Report instead.
|
||||
func (c *Client) ReportBug(os, osVersion, title, description, username, email string) (err error) {
|
||||
return c.ReportBugWithEmailClient(os, osVersion, title, description, username, email, "")
|
||||
}
|
||||
|
||||
// ReportBugWithEmailClient is old. Use Report instead.
|
||||
func (c *Client) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) (err error) {
|
||||
bugReq := ReportReq{
|
||||
OS: os,
|
||||
OSVersion: osVersion,
|
||||
Browser: emailClient,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Username: username,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
return c.Report(bugReq)
|
||||
}
|
||||
|
||||
// ReportCrash is old. Use sentry instead.
|
||||
func (c *Client) ReportCrash(stacktrace string) (err error) {
|
||||
crashReq := ReportReq{
|
||||
Client: c.config.ClientID,
|
||||
ClientVersion: c.config.AppVersion,
|
||||
ClientType: EmailClientType,
|
||||
OS: runtime.GOOS,
|
||||
Debug: stacktrace,
|
||||
}
|
||||
req, err := 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
|
||||
}
|
||||
180
pkg/pmapi/bugs_test.go
Normal file
180
pkg/pmapi/bugs_test.go
Normal file
@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testBugsReportReq = ReportReq{
|
||||
OS: "Mac OSX",
|
||||
OSVersion: "10.11.6",
|
||||
Client: "demoapp",
|
||||
ClientVersion: "GoPMAPI_1.0.14",
|
||||
ClientType: 1,
|
||||
Title: "Big Bug",
|
||||
Description: "Cannot fetch new messages",
|
||||
Username: "apple",
|
||||
Email: "apple@gmail.com",
|
||||
}
|
||||
|
||||
var testBugsReportReqWithEmailClient = ReportReq{
|
||||
OS: "Mac OSX",
|
||||
OSVersion: "10.11.6",
|
||||
Browser: "AppleMail",
|
||||
Client: "demoapp",
|
||||
ClientVersion: "GoPMAPI_1.0.14",
|
||||
ClientType: 1,
|
||||
Title: "Big Bug",
|
||||
Description: "Cannot fetch new messages",
|
||||
Username: "Apple",
|
||||
Email: "apple@gmail.com",
|
||||
}
|
||||
|
||||
var testBugsCrashReq = ReportReq{
|
||||
OS: runtime.GOOS,
|
||||
Client: "demoapp",
|
||||
ClientVersion: "GoPMAPI_1.0.14",
|
||||
ClientType: 1,
|
||||
Debug: "main.func·001()\n/Users/sunny/Code/Go/src/scratch/stack.go:21 +0xabruntime.panic(0x80b80, 0x2101fb150)\n/usr/local/Cellar/go/1.2/libexec/src/pkg/runtime/panic.c:248 +0x106\nmain.inner()/Users/sunny/Code/Go/src/scratch/stack.go:27 +0x68\nmain.outer()\n/Users/sunny/Code/Go/src/scratch/stack.go:13 +0x1a\nmain.main()\n/Users/sunny/Code/Go/src/scratch/stack.go:9 +0x1a",
|
||||
}
|
||||
|
||||
const testBugsBody = `{
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
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_BugReport(t *testing.T) {
|
||||
s, c := newTestServer(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
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq))
|
||||
Equals(t, testBugsReportReq, bugsReportReq)
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
Ok(t, c.ReportBug(
|
||||
testBugsReportReq.OS,
|
||||
testBugsReportReq.OSVersion,
|
||||
testBugsReportReq.Title,
|
||||
testBugsReportReq.Description,
|
||||
testBugsReportReq.Username,
|
||||
testBugsReportReq.Email,
|
||||
))
|
||||
}
|
||||
|
||||
func TestClient_BugReportWithAttachment(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/reports/bug"))
|
||||
Ok(t, isAuthReq(r, testUID, testAccessToken))
|
||||
|
||||
Ok(t, r.ParseMultipartForm(10*1024))
|
||||
|
||||
for field, expected := range map[string]string{
|
||||
"OS": testBugsReportReq.OS,
|
||||
"OSVersion": testBugsReportReq.OSVersion,
|
||||
"Client": testBugsReportReq.Client,
|
||||
"ClientVersion": testBugsReportReq.ClientVersion,
|
||||
"ClientType": fmt.Sprintf("%d", testBugsReportReq.ClientType),
|
||||
"Title": testBugsReportReq.Title,
|
||||
"Description": testBugsReportReq.Description,
|
||||
"Username": testBugsReportReq.Username,
|
||||
"Email": testBugsReportReq.Email,
|
||||
} {
|
||||
if r.PostFormValue(field) != expected {
|
||||
t.Errorf("Field %q has %q but expected %q", field, r.PostFormValue(field), expected)
|
||||
}
|
||||
}
|
||||
|
||||
attReader, err := r.MultipartForm.File["log"][0].Open()
|
||||
Ok(t, err)
|
||||
|
||||
log, err := ioutil.ReadAll(attReader)
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, []byte(testAttachmentJSONZipped), log)
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
rep := testBugsReportReq
|
||||
rep.AddAttachment("log", "last.log", strings.NewReader(testAttachmentJSON))
|
||||
|
||||
Ok(t, c.Report(rep))
|
||||
}
|
||||
|
||||
func TestClient_BugReportWithEmailClient(t *testing.T) {
|
||||
s, c := newTestServer(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
|
||||
Ok(t, json.NewDecoder(r.Body).Decode(&bugsReportReq))
|
||||
Equals(t, testBugsReportReqWithEmailClient, bugsReportReq)
|
||||
|
||||
fmt.Fprint(w, testBugsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
Ok(t, c.ReportBugWithEmailClient(
|
||||
testBugsReportReqWithEmailClient.OS,
|
||||
testBugsReportReqWithEmailClient.OSVersion,
|
||||
testBugsReportReqWithEmailClient.Title,
|
||||
testBugsReportReqWithEmailClient.Description,
|
||||
testBugsReportReqWithEmailClient.Username,
|
||||
testBugsReportReqWithEmailClient.Email,
|
||||
testBugsReportReqWithEmailClient.Browser,
|
||||
))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
503
pkg/pmapi/client.go
Normal file
503
pkg/pmapi/client.go
Normal file
@ -0,0 +1,503 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/jaytaylor/html2text"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Version of the API.
|
||||
const Version = 3
|
||||
|
||||
// API return codes.
|
||||
const (
|
||||
ForceUpgradeBadAPIVersion = 5003
|
||||
ForceUpgradeInvalidAPI = 5004
|
||||
ForceUpgradeBadAppVersion = 5005
|
||||
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")
|
||||
)
|
||||
|
||||
type ErrUnauthorized struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (err *ErrUnauthorized) Error() string {
|
||||
return fmt.Sprintf("unauthorized access: %+v", err.error.Error())
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
tokensLocker sync.Locker
|
||||
tokenMap map[string]string
|
||||
}
|
||||
|
||||
func NewTokenManager() *TokenManager {
|
||||
tm := &TokenManager{
|
||||
tokensLocker: &sync.Mutex{},
|
||||
tokenMap: map[string]string{},
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
func (tm *TokenManager) GetToken(userID string) string {
|
||||
tm.tokensLocker.Lock()
|
||||
defer tm.tokensLocker.Unlock()
|
||||
|
||||
return tm.tokenMap[userID]
|
||||
}
|
||||
|
||||
func (tm *TokenManager) SetToken(userID, token string) {
|
||||
tm.tokensLocker.Lock()
|
||||
defer tm.tokensLocker.Unlock()
|
||||
|
||||
tm.tokenMap[userID] = token
|
||||
}
|
||||
|
||||
// ClientConfig contains Client configuration.
|
||||
type ClientConfig struct {
|
||||
// The client application name and version.
|
||||
AppVersion string
|
||||
|
||||
// The client ID.
|
||||
ClientID string
|
||||
|
||||
TokenManager *TokenManager
|
||||
|
||||
// Transport specifies the mechanism by which individual HTTP requests are made.
|
||||
// If nil, http.DefaultTransport is used.
|
||||
Transport http.RoundTripper
|
||||
|
||||
// Timeout specifies the timeout from request to getting response headers to our API.
|
||||
// Passed to http.Client, empty means no timeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// FirstReadTimeout specifies the timeout from getting response to the first read of body response.
|
||||
// This timeout is applied only when MinSpeed is used.
|
||||
// Default is 5 minutes.
|
||||
FirstReadTimeout time.Duration
|
||||
|
||||
// MinSpeed specifies minimum Bytes per second or the request will be canceled.
|
||||
// Zero means no limitation.
|
||||
MinSpeed int64
|
||||
}
|
||||
|
||||
// Client to communicate with API.
|
||||
type Client struct {
|
||||
auths chan<- *Auth // Channel that sends Auth responses back to the bridge.
|
||||
|
||||
log *logrus.Entry
|
||||
config *ClientConfig
|
||||
client *http.Client
|
||||
conrep ConnectionReporter
|
||||
|
||||
uid string
|
||||
accessToken string
|
||||
userID string // Twice here because Username is not unique.
|
||||
requestLocker sync.Locker
|
||||
keyLocker sync.Locker
|
||||
|
||||
tokenManager *TokenManager
|
||||
expiresAt time.Time
|
||||
user *User
|
||||
addresses AddressList
|
||||
kr *pmcrypto.KeyRing
|
||||
}
|
||||
|
||||
// NewClient creates a new API client.
|
||||
func NewClient(cfg *ClientConfig, userID string) *Client {
|
||||
hc := &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
if cfg.Transport != nil {
|
||||
cfgTransport, ok := cfg.Transport.(*http.Transport)
|
||||
if ok {
|
||||
// In future use Clone here.
|
||||
// https://go-review.googlesource.com/c/go/+/174597/
|
||||
transport := &http.Transport{}
|
||||
*transport = *cfgTransport //nolint
|
||||
if transport.Proxy == nil {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
hc.Transport = transport
|
||||
} else {
|
||||
hc.Transport = cfg.Transport
|
||||
}
|
||||
} else if defaultTransport != nil {
|
||||
hc.Transport = defaultTransport
|
||||
}
|
||||
|
||||
log := logrus.WithFields(logrus.Fields{
|
||||
"pkg": "pmapi",
|
||||
"userID": userID,
|
||||
})
|
||||
|
||||
return &Client{
|
||||
log: log,
|
||||
config: cfg,
|
||||
client: hc,
|
||||
tokenManager: cfg.TokenManager,
|
||||
userID: userID,
|
||||
requestLocker: &sync.Mutex{},
|
||||
keyLocker: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetConnectionReporter sets the connection reporter used by the client to report when
|
||||
// internet connection is lost.
|
||||
func (c *Client) SetConnectionReporter(conrep ConnectionReporter) {
|
||||
c.conrep = conrep
|
||||
}
|
||||
|
||||
// reportLostConnection reports that the internet connection has been lost using the connection reporter.
|
||||
// If the connection reporter has not been set, this does nothing.
|
||||
func (c *Client) reportLostConnection() {
|
||||
if c.conrep != nil {
|
||||
err := c.conrep.NotifyConnectionLost()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to notify of lost connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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("x-pm-appversion", c.config.AppVersion)
|
||||
req.Header.Set("x-pm-apiversion", strconv.Itoa(Version))
|
||||
|
||||
if c.uid != "" {
|
||||
req.Header.Set("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'", string(bodyBuffer))
|
||||
}
|
||||
|
||||
hasBody := len(bodyBuffer) > 0
|
||||
if res, err = c.client.Do(req); err != nil {
|
||||
if res == nil {
|
||||
c.log.WithError(err).Error("Cannot get response")
|
||||
err = ErrAPINotReachable
|
||||
c.reportLostConnection()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
resDate := res.Header.Get("Date")
|
||||
if resDate != "" {
|
||||
if serverTime, err := http.ParseTime(resDate); err == nil {
|
||||
pmcrypto.GetGopenPGP().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)
|
||||
|
||||
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.config.MinSpeed > 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.config.MinSpeed == 0 {
|
||||
resBody, err = ioutil.ReadAll(res.Body)
|
||||
} else {
|
||||
resBody, err = c.readAllMinSpeed(res.Body, cancelRequest)
|
||||
}
|
||||
|
||||
// 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 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.config.FirstReadTimeout
|
||||
if firstReadTimeout == 0 {
|
||||
firstReadTimeout = 5 * time.Minute
|
||||
}
|
||||
timer := time.AfterFunc(firstReadTimeout, func() {
|
||||
cancelRequest()
|
||||
})
|
||||
var buffer bytes.Buffer
|
||||
for {
|
||||
_, err := io.CopyN(&buffer, data, c.config.MinSpeed)
|
||||
timer.Stop()
|
||||
timer.Reset(1 * time.Second)
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return ioutil.ReadAll(&buffer)
|
||||
}
|
||||
|
||||
func (c *Client) refreshAccessToken() (err error) {
|
||||
c.log.Debug("Refreshing token")
|
||||
refreshToken := c.tokenManager.GetToken(c.userID)
|
||||
c.log.WithField("token", refreshToken).Info("Current refresh token")
|
||||
if refreshToken == "" {
|
||||
if c.auths != nil {
|
||||
c.auths <- nil
|
||||
}
|
||||
if c.tokenManager != nil {
|
||||
c.tokenManager.SetToken(c.userID, "")
|
||||
}
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
auth, err := c.AuthRefresh(refreshToken)
|
||||
if err != nil {
|
||||
c.log.WithError(err).WithField("auths", c.auths).Debug("Token refreshing failed")
|
||||
// The refresh failed, so we should log the user out.
|
||||
// A nil value in the Auths channel will trigger this.
|
||||
if c.auths != nil {
|
||||
c.auths <- nil
|
||||
}
|
||||
if c.tokenManager != nil {
|
||||
c.tokenManager.SetToken(c.userID, "")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.uid = auth.UID()
|
||||
c.accessToken = auth.accessToken
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
c.log.WithError(err).Warn("Failed to read out response body")
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
return c.doBuffered(req, reqBodyBuffer, true)
|
||||
}
|
||||
215
pkg/pmapi/client_test.go
Normal file
215
pkg/pmapi/client_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2020 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,
|
||||
MinSpeed: 256,
|
||||
}
|
||||
|
||||
func newTestClient() *Client {
|
||||
c := NewClient(testClientConfig, "tester")
|
||||
c.tokenManager = NewTokenManager()
|
||||
return c
|
||||
}
|
||||
|
||||
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 := 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-apiversion") != fmt.Sprintf("%v", Version) {
|
||||
t.Fatalf("Invalid api version header: expected %v, got %v", Version, h.Get("x-pm-apiversion"))
|
||||
}
|
||||
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.client.Transport = &slowTransport{
|
||||
transport: c.client.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(2*time.Second),
|
||||
)
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
43
pkg/pmapi/config.go
Normal file
43
pkg/pmapi/config.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// RootURL is the API root URL.
|
||||
//
|
||||
// This can be changed using build flags: pmapi_local for "http://localhost/api",
|
||||
// pmapi_dev or pmapi_prod. Default is pmapi_prod.
|
||||
var RootURL = "https://api.protonmail.ch" //nolint[gochecknoglobals]
|
||||
|
||||
// CurrentUserAgent is the default User-Agent for go-pmapi lib. This can be changed to program
|
||||
// version and email client.
|
||||
// e.g. Bridge/1.0.4 (Windows) MicrosoftOutlook/16.0.9330.2087
|
||||
var CurrentUserAgent = "GoPMAPI/1.0.14 (" + runtime.GOOS + "; no client)" //nolint[gochecknoglobals]
|
||||
|
||||
// The HTTP transport to use by default.
|
||||
var defaultTransport = &http.Transport{ //nolint[gochecknoglobals]
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
|
||||
// checkTLSCerts controls whether TLS certs are checked against known fingerprints.
|
||||
// The default is for this to always be done.
|
||||
var checkTLSCerts = true //nolint[gochecknoglobals]
|
||||
24
pkg/pmapi/config_dev.go
Normal file
24
pkg/pmapi/config_dev.go
Normal file
@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2020 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 pmapi_dev
|
||||
|
||||
package pmapi
|
||||
|
||||
func init() {
|
||||
RootURL = "https://dev.protonmail.com/api"
|
||||
}
|
||||
37
pkg/pmapi/config_local.go
Normal file
37
pkg/pmapi/config_local.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2020 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 pmapi_local
|
||||
|
||||
package pmapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Use port above 1000 which doesn't need root access to start anything on it.
|
||||
// Now the port is rounded pi. :-)
|
||||
RootURL = "http://127.0.0.1:3142/api"
|
||||
|
||||
// TLS certificate is self-signed
|
||||
defaultTransport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
25
pkg/pmapi/config_nopin.go
Normal file
25
pkg/pmapi/config_nopin.go
Normal file
@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2020 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 pmapi_nopin
|
||||
|
||||
package pmapi
|
||||
|
||||
func init() {
|
||||
// This config disables TLS cert checking.
|
||||
checkTLSCerts = false
|
||||
}
|
||||
23
pkg/pmapi/conrep.go
Normal file
23
pkg/pmapi/conrep.go
Normal file
@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2020 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
|
||||
}
|
||||
430
pkg/pmapi/contacts.go
Normal file
430
pkg/pmapi/contacts.go
Normal file
@ -0,0 +1,430 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Card struct {
|
||||
Type int
|
||||
Data string
|
||||
Signature string
|
||||
}
|
||||
|
||||
const (
|
||||
CardEncrypted = 1
|
||||
CardSigned = 2
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
ID string
|
||||
Name string
|
||||
UID string
|
||||
Size int64
|
||||
CreateTime int64
|
||||
ModifyTime int64
|
||||
LabelIDs []string
|
||||
|
||||
ContactEmails []ContactEmail
|
||||
Cards []Card
|
||||
}
|
||||
|
||||
type ContactEmail struct {
|
||||
ID string
|
||||
Name string
|
||||
Email string
|
||||
Type []string
|
||||
Defaults int
|
||||
Order int
|
||||
ContactID string
|
||||
LabelIDs []string
|
||||
}
|
||||
|
||||
var errVerificationFailed = errors.New("signature verification failed")
|
||||
|
||||
//================= Public utility functions ======================
|
||||
|
||||
func (c *Client) EncryptAndSignCards(cards []Card) ([]Card, error) {
|
||||
var err error
|
||||
for i := range cards {
|
||||
card := &cards[i]
|
||||
if isEncryptedCardType(card.Type) {
|
||||
if isSignedCardType(card.Type) {
|
||||
if card.Signature, err = c.sign(card.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if card.Data, err = c.encrypt(card.Data, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if isSignedCardType(card.Type) {
|
||||
if card.Signature, err = c.sign(card.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (c *Client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
|
||||
for i := range cards {
|
||||
card := &cards[i]
|
||||
if isEncryptedCardType(card.Type) {
|
||||
signedCard, err := c.decrypt(card.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
card.Data = signedCard
|
||||
}
|
||||
if isSignedCardType(card.Type) {
|
||||
err := c.verify(card.Data, card.Signature)
|
||||
if err != nil {
|
||||
return cards, errVerificationFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
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 := 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 := NewRequest("GET", "/contacts/"+id, nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type ContactRes struct {
|
||||
Res
|
||||
Contact Contact
|
||||
}
|
||||
var res ContactRes
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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 := 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 := 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
|
||||
}
|
||||
|
||||
// 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 := NewRequest("GET", "/contacts/emails?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res ContactsEmailsRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
contactEmails, err = res.ContactEmails, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
//============================ 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 := 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 := 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 := 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 := 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 := 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
|
||||
}
|
||||
|
||||
func isEncryptedCardType(cardType int) bool {
|
||||
return (cardType & CardEncrypted) == CardEncrypted
|
||||
}
|
||||
677
pkg/pmapi/contacts_test.go
Normal file
677
pkg/pmapi/contacts_test.go
Normal file
@ -0,0 +1,677 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var (
|
||||
CleartextCard = 0
|
||||
EncryptedCard = 1
|
||||
SignedCard = 2
|
||||
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": {
|
||||
"ID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"Name": "Alice",
|
||||
"UID": "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
|
||||
"Size": 243,
|
||||
"CreateTime": 1517395498,
|
||||
"ModifyTime": 1517395498,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"ContactEmails": [
|
||||
{
|
||||
"ID": "4m2sBxLq4McqD0D330Kuy5xG-yyDNXyLEjG5_RYcjy9X-3qHGNP07DNOWLY40TYtUAQr4fAVp8zOcZ_z2o6H-A==",
|
||||
"Name": "Alice",
|
||||
"Email": "alice@protonmail.com",
|
||||
"Type": [],
|
||||
"Defaults": 1,
|
||||
"Order": 1,
|
||||
"ContactID": "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
"LabelIDs": []
|
||||
}
|
||||
],
|
||||
"LabelIDs": []
|
||||
}
|
||||
}`
|
||||
|
||||
var testGetContactByID = Contact{
|
||||
ID: "s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==",
|
||||
Name: "Alice",
|
||||
UID: "proton-web-98c8de5e-4536-140b-9ab0-bd8ab6a2050b",
|
||||
Size: 243,
|
||||
CreateTime: 1517395498,
|
||||
ModifyTime: 1517395498,
|
||||
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",
|
||||
},
|
||||
},
|
||||
ContactEmails: []ContactEmail{
|
||||
{
|
||||
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{},
|
||||
},
|
||||
},
|
||||
LabelIDs: []string{},
|
||||
}
|
||||
|
||||
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=="))
|
||||
|
||||
fmt.Fprint(w, testGetContactByIDResponseBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
contact, err := c.GetContactByID("s_SN9y1q0jczjYCH4zhvfOdHv1QNovKhnJ9bpDcTE0u7WCr2Z-NV9uubHXvOuRozW-HRVam6bQupVYRMC3BCqg==")
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while getting contacts, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(contact, testGetContactByID) {
|
||||
t.Fatalf("Invalid got contact: expected %+v, got %+v", testGetContactByID, contact)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if isSignedCardType(CleartextCard) || isSignedCardType(EncryptedCard) {
|
||||
t.Fatal("isSignedCardType shouldn't return true for non-signed card types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContact_isEncryptedCardType(t *testing.T) {
|
||||
if !isEncryptedCardType(EncryptedCard) || !isEncryptedCardType(EncryptedSignedCard) {
|
||||
t.Fatal("isEncryptedCardType shouldn't return false for encrypted card types")
|
||||
}
|
||||
if isEncryptedCardType(CleartextCard) || isEncryptedCardType(SignedCard) {
|
||||
t.Fatal("isEncryptedCardType shouldn't return true for non-encrypted card types")
|
||||
}
|
||||
}
|
||||
|
||||
var testCardsEncrypted = []Card{
|
||||
{
|
||||
Type: EncryptedSignedCard,
|
||||
Data: "-----BEGIN PGP MESSAGE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwcBMA0fcZ7XLgmf2AQf/fLKA6ZCkDxumpDoUoFQfO86B9LFuqGEJq+voP12C6UXo\nfB2nTy/K4+VosLKYOkU9sW1PZOCL+i00z+zkqUZ6jchbZBpzwy/UCTmpPRw5zrmr\nW6bZCwwgqJSGVWrvcrDA3bW9cn/HHqQqU6jNeXIF+IuhTscRAJVGehJZYWjr1lgB\nToJhg4+//Bgp/Fxzz8Fej/fsokgOlRJ8xcZKYx0rKL/+Il0u2jnd08kJTegpaY+6\nBlsYBzfYq25WkS02iy02wHbt6XD7AxFDi4WDjsM8bryLSm/KNWrejqfDYb/tMAKa\nKNJqK39/EUewzp1gHEXiGmdDEIFTKCHTDTPV84mwf9I1Ae4yoLs+ilYE6sSk7DCh\nPSWjDC8lpKzmw93slsejTG93HJKQPcZ0rLBpv6qPZX6widNYjDE=\n=QFxr\n-----END PGP MESSAGE-----",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----",
|
||||
},
|
||||
}
|
||||
|
||||
var testCardsCleartext = []Card{
|
||||
{
|
||||
Type: EncryptedSignedCard,
|
||||
Data: "data",
|
||||
Signature: "-----BEGIN PGP SIGNATURE-----\nVersion: GopenPGP 0.0.1 (ddacebe0)\nComment: https://gopenpgp.org\n\nwsBcBAABCgAQBQJdZQ1kCRA+tiWe3yHfJAAA9nMH/0X7pS8TGt6Ox0BewRh0vjfQ\n9LPLwbOiHdj97LNqutZcLlDTfm9SPH82221ZpVILWhB0u2kFeNUGihVbjAqJGYJn\nEk2TELLwn8csYRy9r5JkyUirqrvh7jgl4vs1yt8O/3Yb4ARudOoZr8Yrb4+NVNe0\nCcwQJnH/fJPtF1hbarKwtKtCo3IFwTis4pc8qWJRpBH61z1mO0Yr/LIh85QndhnF\nnZ/3MkWOY0kp2gl4ptqtNUw7z+JJ4LLVdT3ycdVK7GVTZmIG90y5KKxwJvrwbS7/\n8rmPGPQ5diLEMrzuKC2plXT6Pdy0ShtZxie2C3JY86e7ol7xvl0pNqxzOrj424w=\n=AOTG\n-----END PGP SIGNATURE-----",
|
||||
},
|
||||
}
|
||||
|
||||
func TestClient_Encrypt(t *testing.T) {
|
||||
c := newTestClient()
|
||||
c.kr = testPrivateKeyRing
|
||||
|
||||
cardEncrypted, err := c.EncryptAndSignCards(testCardsCleartext)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Result is always different, so the best way is to test it by decrypting again.
|
||||
// Another test for decrypting will help us to be sure it's working.
|
||||
cardCleartext, err := c.DecryptAndVerifyCards(cardEncrypted)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data)
|
||||
}
|
||||
|
||||
func TestClient_Decrypt(t *testing.T) {
|
||||
c := newTestClient()
|
||||
c.kr = testPrivateKeyRing
|
||||
|
||||
cardCleartext, err := c.DecryptAndVerifyCards(testCardsEncrypted)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data)
|
||||
}
|
||||
51
pkg/pmapi/conversations.go
Normal file
51
pkg/pmapi/conversations.go
Normal file
@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2020 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 := "/conversations/count"
|
||||
if addressID != "" {
|
||||
reqURL += ("?AddressID=" + addressID)
|
||||
}
|
||||
req, err := 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
|
||||
}
|
||||
373
pkg/pmapi/dialer_with_proxy.go
Normal file
373
pkg/pmapi/dialer_with_proxy.go
Normal file
@ -0,0 +1,373 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TLSReport is inspired by https://tools.ietf.org/html/rfc7469#section-3.
|
||||
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"`
|
||||
}
|
||||
|
||||
// ErrTLSMatch indicates that no TLS fingerprint match could be found.
|
||||
var ErrTLSMatch = fmt.Errorf("TLS fingerprint match not found")
|
||||
|
||||
// DialerWithPinning will provide dial function which checks the fingerprints of public cert
|
||||
// received from contacted server. If no match found among know pinse it will report using
|
||||
// ReportCertIssueLocal.
|
||||
type DialerWithPinning struct {
|
||||
// isReported will stop reporting if true.
|
||||
isReported bool
|
||||
|
||||
// report stores known pins.
|
||||
report TLSReport
|
||||
|
||||
// When reportURI is not empty the tls issue report will be send to this URI.
|
||||
reportURI string
|
||||
|
||||
// ReportCertIssueLocal is used send signal to application about certificate issue.
|
||||
// It is used only if set.
|
||||
ReportCertIssueLocal func()
|
||||
|
||||
// proxyManager manages API proxies.
|
||||
proxyManager *proxyManager
|
||||
|
||||
// A logger for logging messages.
|
||||
log logrus.FieldLogger
|
||||
}
|
||||
|
||||
func NewDialerWithPinning(reportURI string, report TLSReport) *DialerWithPinning {
|
||||
log := logrus.WithField("pkg", "pmapi/tls-pinning")
|
||||
|
||||
proxyManager := newProxyManager(dohProviders, proxyQuery)
|
||||
|
||||
return &DialerWithPinning{
|
||||
isReported: false,
|
||||
reportURI: reportURI,
|
||||
report: report,
|
||||
proxyManager: proxyManager,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPMAPIPinning(appVersion string) *DialerWithPinning {
|
||||
return NewDialerWithPinning(
|
||||
"https://reports.protonmail.ch/reports/tls",
|
||||
TLSReport{
|
||||
EffectiveExpirationDate: time.Now().Add(365 * 24 * 60 * 60 * time.Second).Format(time.RFC3339),
|
||||
IncludeSubdomains: false,
|
||||
ValidatedCertificateChain: []string{},
|
||||
ServedCertificateChain: []string{},
|
||||
AppVersion: appVersion,
|
||||
|
||||
// NOTE: the proxy pins are the same for all proxy servers, guaranteed by infra team ;)
|
||||
KnownPins: []string{
|
||||
`pin-sha256="drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE="`, // current
|
||||
`pin-sha256="YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54="`, // hot
|
||||
`pin-sha256="AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="`, // cold
|
||||
`pin-sha256="EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM="`, // proxy main
|
||||
`pin-sha256="iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA="`, // proxy backup 1
|
||||
`pin-sha256="MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA="`, // proxy backup 2
|
||||
`pin-sha256="C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw="`, // proxy backup 3
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (p *DialerWithPinning) reportCertIssue(connState tls.ConnectionState) {
|
||||
p.isReported = true
|
||||
|
||||
if p.ReportCertIssueLocal != nil {
|
||||
go p.ReportCertIssueLocal()
|
||||
}
|
||||
|
||||
if p.reportURI != "" {
|
||||
p.report.NotedHostname = connState.ServerName
|
||||
p.report.ServedCertificateChain = marshalCert7468(connState.PeerCertificates)
|
||||
|
||||
if len(connState.VerifiedChains) > 0 {
|
||||
p.report.ServedCertificateChain = marshalCert7468(
|
||||
connState.VerifiedChains[len(connState.VerifiedChains)-1],
|
||||
)
|
||||
}
|
||||
|
||||
go p.reportCertIssueRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DialerWithPinning) reportCertIssueRemote() {
|
||||
b, err := json.Marshal(p.report)
|
||||
if err != nil {
|
||||
p.log.Errorf("marshal request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", p.reportURI, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
p.log.Errorf("create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", CurrentUserAgent)
|
||||
req.Header.Set("x-pm-apiversion", strconv.Itoa(Version))
|
||||
req.Header.Set("x-pm-appversion", p.report.AppVersion)
|
||||
|
||||
p.log.Debugf("report req: %+v\n", req)
|
||||
|
||||
c := &http.Client{}
|
||||
res, err := c.Do(req)
|
||||
p.log.Debugf("res: %+v\nerr: %v", res, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = ioutil.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
p.log.Errorf("response status: %v", res.Status)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
|
||||
func certFingerprint(cert *x509.Certificate) string {
|
||||
hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return fmt.Sprintf(`pin-sha256=%q`, base64.StdEncoding.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
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").Errorf("encoding TLS cert: %v", err)
|
||||
}
|
||||
pemCerts = append(pemCerts, buffer.String())
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
return pemCerts
|
||||
}
|
||||
|
||||
func (p *DialerWithPinning) TransportWithPinning() *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialTLS: p.dialAndCheckFingerprints,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// dialAndCheckFingerprint to set as http.Transport.DialTLS.
|
||||
//
|
||||
// * note that when DialTLS is not nil the Transport.TLSClientConfig and Transport.TLSHandshakeTimeout are ignored.
|
||||
// * dialAndCheckFingerprints fails if certificate is not valid (not signed by authority or not matching hostname).
|
||||
// * dialAndCheckFingerprints will pass if certificate pin does not have a match, but will send notification using
|
||||
// p.ReportCertIssueLocal() and p.reportCertIssueRemote() if they are not nil.
|
||||
func (p *DialerWithPinning) dialAndCheckFingerprints(network, address string) (conn net.Conn, err error) {
|
||||
// If DoH is enabled, we hardfail on fingerprint mismatches.
|
||||
if globalIsDoHAllowed() && p.isReported {
|
||||
return nil, ErrTLSMatch
|
||||
}
|
||||
|
||||
// Try to dial the given address but use a proxy if necessary.
|
||||
if conn, err = p.dialWithProxyFallback(network, address); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If cert issue was already reported, we don't want to check fingerprints anymore.
|
||||
if p.isReported {
|
||||
return nil, ErrTLSMatch
|
||||
}
|
||||
|
||||
// Check the cert fingerprint to ensure it is known.
|
||||
if err = p.checkFingerprints(conn); err != nil {
|
||||
p.log.WithError(err).Error("Error checking cert fingerprints")
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// dialWithProxyFallback tries to dial the given address but falls back to alternative proxies if need be.
|
||||
func (p *DialerWithPinning) dialWithProxyFallback(network, address string) (conn net.Conn, err error) {
|
||||
var host, port string
|
||||
if host, port, err = net.SplitHostPort(address); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to dial, and if it succeeds, then just return.
|
||||
if conn, err = p.dial(network, address); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If DoH is not allowed, give up. Or, if we are dialing something other than the API
|
||||
// (e.g. we dial protonmail.com/... to check for updates), there's also no point in
|
||||
// continuing since a proxy won't help us reach that.
|
||||
if !globalIsDoHAllowed() || host != stripProtocol(GlobalGetRootURL()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find a new proxy.
|
||||
var proxy string
|
||||
if proxy, err = p.proxyManager.findProxy(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Switch to the proxy.
|
||||
p.log.WithField("proxy", proxy).Debug("Switching to proxy")
|
||||
p.proxyManager.useProxy(proxy)
|
||||
|
||||
// Retry dial with proxy.
|
||||
return p.dial(network, net.JoinHostPort(proxy, port))
|
||||
}
|
||||
|
||||
// dial returns a connection to the given address using the given network.
|
||||
func (p *DialerWithPinning) dial(network, address string) (conn net.Conn, err error) {
|
||||
var port string
|
||||
if p.report.Hostname, port, err = net.SplitHostPort(address); err != nil {
|
||||
return
|
||||
}
|
||||
if p.report.Port, err = strconv.Atoi(port); err != nil {
|
||||
return
|
||||
}
|
||||
p.report.DateTime = time.Now().Format(time.RFC3339)
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
|
||||
// If we are not dialing the standard API then we should skip cert verification checks.
|
||||
var tlsConfig *tls.Config = nil
|
||||
if address != stripProtocol(globalOriginalURL) {
|
||||
tlsConfig = &tls.Config{InsecureSkipVerify: true} // nolint[gosec]
|
||||
}
|
||||
|
||||
return tls.DialWithDialer(dialer, network, address, tlsConfig)
|
||||
}
|
||||
|
||||
func (p *DialerWithPinning) checkFingerprints(conn net.Conn) (err error) {
|
||||
if !checkTLSCerts {
|
||||
return
|
||||
}
|
||||
|
||||
connState := conn.(*tls.Conn).ConnectionState()
|
||||
|
||||
hasFingerprintMatch := false
|
||||
for _, peerCert := range connState.PeerCertificates {
|
||||
fingerprint := certFingerprint(peerCert)
|
||||
|
||||
for i, pin := range p.report.KnownPins {
|
||||
if pin == fingerprint {
|
||||
hasFingerprintMatch = true
|
||||
|
||||
if i != 0 {
|
||||
p.log.Warnf("Matched fingerprint (%q) was not primary pinned key (was key #%d)", fingerprint, i)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasFingerprintMatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFingerprintMatch {
|
||||
p.reportCertIssue(connState)
|
||||
return ErrTLSMatch
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
126
pkg/pmapi/dialer_with_proxy_test.go
Normal file
126
pkg/pmapi/dialer_with_proxy_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2020 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"
|
||||
)
|
||||
|
||||
const liveAPI = "https://api.protonmail.ch"
|
||||
|
||||
var testLiveConfig = &ClientConfig{
|
||||
AppVersion: "Bridge_1.2.4-test",
|
||||
ClientID: "Bridge",
|
||||
}
|
||||
|
||||
func newTestDialerWithPinning() (*int, *DialerWithPinning) {
|
||||
called := 0
|
||||
p := NewPMAPIPinning(testLiveConfig.AppVersion)
|
||||
p.ReportCertIssueLocal = func() { called++ }
|
||||
testLiveConfig.Transport = p.TransportWithPinning()
|
||||
return &called, p
|
||||
}
|
||||
|
||||
func TestTLSPinValid(t *testing.T) {
|
||||
called, _ := newTestDialerWithPinning()
|
||||
|
||||
RootURL = liveAPI
|
||||
client := NewClient(testLiveConfig, "pmapi"+t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 0, *called)
|
||||
}
|
||||
|
||||
func TestTLSPinBackup(t *testing.T) {
|
||||
called, p := newTestDialerWithPinning()
|
||||
p.report.KnownPins[1] = p.report.KnownPins[0]
|
||||
p.report.KnownPins[0] = ""
|
||||
|
||||
RootURL = liveAPI
|
||||
client := NewClient(testLiveConfig, "pmapi"+t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 0, *called)
|
||||
}
|
||||
|
||||
func _TestTLSPinNoMatch(t *testing.T) { // nolint[unused]
|
||||
called, p := newTestDialerWithPinning()
|
||||
for i := 0; i < len(p.report.KnownPins); i++ {
|
||||
p.report.KnownPins[i] = "testing"
|
||||
}
|
||||
|
||||
RootURL = liveAPI
|
||||
client := NewClient(testLiveConfig, "pmapi"+t.Name())
|
||||
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
// check that it will be called only once per session
|
||||
client = NewClient(testLiveConfig, "pmapi"+t.Name())
|
||||
_, err = client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, 1, *called)
|
||||
}
|
||||
|
||||
func _TestTLSPinInvalid(t *testing.T) { // nolint[unused]
|
||||
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, _ := newTestDialerWithPinning()
|
||||
|
||||
client := NewClient(testLiveConfig, "pmapi"+t.Name())
|
||||
|
||||
RootURL = liveAPI
|
||||
_, err := client.AuthInfo("this.address.is.disabled")
|
||||
Ok(t, err)
|
||||
|
||||
RootURL = ts.URL
|
||||
_, err = client.AuthInfo("this.address.is.disabled")
|
||||
Assert(t, err != nil, "error is expected but have %v", err)
|
||||
|
||||
Equals(t, 1, *called)
|
||||
}
|
||||
|
||||
func _TestTLSSignedCertWrongPublicKey(t *testing.T) { // nolint[unused]
|
||||
_, dialer := newTestDialerWithPinning()
|
||||
_, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443")
|
||||
Assert(t, err != nil, "expected dial to fail because of wrong public key: ", err.Error())
|
||||
}
|
||||
|
||||
func _TestTLSSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
|
||||
_, dialer := newTestDialerWithPinning()
|
||||
dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="W8/42Z0ffufwnHIOSndT+eVzBJSC0E8uTIC8O6mEliQ="`)
|
||||
_, err := dialer.dialAndCheckFingerprints("tcp", "rsa4096.badssl.com:443")
|
||||
Assert(t, err == nil, "expected dial to succeed because public key is known and cert is signed by CA: ", err.Error())
|
||||
}
|
||||
|
||||
func _TestTLSSelfSignedCertTrustedPublicKey(t *testing.T) { // nolint[unused]
|
||||
_, dialer := newTestDialerWithPinning()
|
||||
dialer.report.KnownPins = append(dialer.report.KnownPins, `pin-sha256="9SLklscvzMYj8f+52lp5ze/hY0CFHyLSPQzSpYYIBm8="`)
|
||||
_, err := dialer.dialAndCheckFingerprints("tcp", "self-signed.badssl.com:443")
|
||||
Assert(t, err == nil, "expected dial to succeed because public key is known despite cert being self-signed: ", err.Error())
|
||||
}
|
||||
237
pkg/pmapi/events.go
Normal file
237
pkg/pmapi/events.go
Normal file
@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
)
|
||||
|
||||
// Event represents changes since the last check.
|
||||
type Event struct {
|
||||
// The current event ID.
|
||||
EventID string
|
||||
// If set to one, all cached data must be fetched again.
|
||||
Refresh int
|
||||
// If set to one, fetch more events.
|
||||
More int
|
||||
// Changes applied to messages.
|
||||
Messages []*EventMessage
|
||||
// Counts of messages per labels.
|
||||
MessageCounts []*MessagesCount
|
||||
// Changes applied to labels.
|
||||
Labels []*EventLabel
|
||||
// Current user status.
|
||||
User User
|
||||
// Changes to addresses.
|
||||
Addresses []*EventAddress
|
||||
// Messages to show to the user.
|
||||
Notices []string
|
||||
}
|
||||
|
||||
// EventAction is the action that created a change.
|
||||
type EventAction int
|
||||
|
||||
const (
|
||||
EventDelete EventAction = iota // Item has been deleted.
|
||||
EventCreate // Item has been created.
|
||||
EventUpdate // Item has been updated.
|
||||
EventUpdateFlags // For messages: flags have been updated.
|
||||
)
|
||||
|
||||
// Flags for event refresh.
|
||||
const (
|
||||
EventRefreshMail = 1
|
||||
EventRefreshContact = 2
|
||||
EventRefreshAll = 255
|
||||
)
|
||||
|
||||
// maxNumberOfMergedEvents limits how many events are merged into one. It means
|
||||
// when GetEvent is called and event returns there is more events, it will
|
||||
// automatically fetch next one and merge it up to this number of events.
|
||||
const maxNumberOfMergedEvents = 50
|
||||
|
||||
// EventItem is an item that has changed.
|
||||
type EventItem struct {
|
||||
ID string
|
||||
Action EventAction
|
||||
}
|
||||
|
||||
// EventMessage is a message that has changed.
|
||||
type EventMessage struct {
|
||||
EventItem
|
||||
|
||||
// If the message has been created, the new message.
|
||||
Created *Message `json:"-"`
|
||||
// If the message has been updated, the updated fields.
|
||||
Updated *EventMessageUpdated `json:"-"`
|
||||
}
|
||||
|
||||
// eventMessage defines a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
|
||||
type eventMessage EventMessage
|
||||
|
||||
type rawEventMessage struct {
|
||||
eventMessage
|
||||
|
||||
// This will be parsed depending on the action.
|
||||
Message json.RawMessage `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (em *EventMessage) UnmarshalJSON(b []byte) (err error) {
|
||||
var raw rawEventMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*em = EventMessage(raw.eventMessage)
|
||||
|
||||
switch em.Action {
|
||||
case EventCreate:
|
||||
em.Created = &Message{ID: raw.ID}
|
||||
return json.Unmarshal(raw.Message, em.Created)
|
||||
case EventUpdate, EventUpdateFlags:
|
||||
em.Updated = &EventMessageUpdated{ID: raw.ID}
|
||||
return json.Unmarshal(raw.Message, em.Updated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (em *EventMessage) MarshalJSON() ([]byte, error) {
|
||||
var raw rawEventMessage
|
||||
raw.eventMessage = eventMessage(*em)
|
||||
|
||||
var err error
|
||||
switch em.Action {
|
||||
case EventCreate:
|
||||
raw.Message, err = json.Marshal(em.Created)
|
||||
case EventUpdate, EventUpdateFlags:
|
||||
raw.Message, err = json.Marshal(em.Updated)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
// EventMessageUpdated contains changed fields for an updated message.
|
||||
type EventMessageUpdated struct {
|
||||
ID string
|
||||
|
||||
Subject *string
|
||||
Unread *int
|
||||
Flags *int64
|
||||
Sender *mail.Address
|
||||
ToList *[]*mail.Address
|
||||
CCList *[]*mail.Address
|
||||
BCCList *[]*mail.Address
|
||||
Time int64
|
||||
|
||||
// Fields only present for EventUpdateFlags.
|
||||
LabelIDs []string
|
||||
LabelIDsAdded []string
|
||||
LabelIDsRemoved []string
|
||||
}
|
||||
|
||||
// EventLabel is a label that has changed.
|
||||
type EventLabel struct {
|
||||
EventItem
|
||||
Label *Label
|
||||
}
|
||||
|
||||
// EventAddress is an address that has changed.
|
||||
type EventAddress struct {
|
||||
EventItem
|
||||
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 = 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 = 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)
|
||||
}
|
||||
}
|
||||
|
||||
return event, err
|
||||
}
|
||||
|
||||
// mergeEvents combines an old events and a new events object.
|
||||
// This is not as simple as just blindly joining the two because some things should only be taken from the new events.
|
||||
func mergeEvents(eventsOld *Event, eventsNew *Event) (mergedEvents *Event) {
|
||||
mergedEvents = &Event{
|
||||
EventID: eventsNew.EventID,
|
||||
Refresh: eventsOld.Refresh | eventsNew.Refresh,
|
||||
More: eventsNew.More,
|
||||
Messages: append(eventsOld.Messages, eventsNew.Messages...),
|
||||
MessageCounts: append(eventsOld.MessageCounts, eventsNew.MessageCounts...),
|
||||
Labels: append(eventsOld.Labels, eventsNew.Labels...),
|
||||
User: eventsNew.User,
|
||||
Addresses: append(eventsOld.Addresses, eventsNew.Addresses...),
|
||||
Notices: append(eventsOld.Notices, eventsNew.Notices...),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
524
pkg/pmapi/events_test.go
Normal file
524
pkg/pmapi/events_test.go
Normal file
@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClient_GetEvent(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/latest"))
|
||||
fmt.Fprint(w, testEventBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("")
|
||||
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) {
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/"+testEvent.EventID))
|
||||
fmt.Fprint(w, testEventBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent(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) {
|
||||
switch r.URL.RequestURI() {
|
||||
case "/events/eventID1":
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID1"))
|
||||
fmt.Fprint(w, testEventBodyMore1)
|
||||
case "/events/eventID2":
|
||||
assert.NoError(t, checkMethodAndPath(r, "GET", "/events/eventID2"))
|
||||
fmt.Fprint(w, testEventBodyMore2)
|
||||
default:
|
||||
t.Fail()
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("eventID1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testEventMerged, event)
|
||||
}
|
||||
|
||||
func TestClient_GetEvent_mergeMaxNumberOfEvents(t *testing.T) {
|
||||
numberOfCalls := 0
|
||||
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
numberOfCalls++
|
||||
|
||||
re := regexp.MustCompile(`/eventID([0-9]+)`)
|
||||
eventIDString := re.FindStringSubmatch(r.URL.RequestURI())[1]
|
||||
eventID, err := strconv.Atoi(eventIDString)
|
||||
require.NoError(t, err)
|
||||
|
||||
if numberOfCalls > maxNumberOfMergedEvents*2 {
|
||||
require.Fail(t, "Too many calls!")
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
body := strings.ReplaceAll(testEventBodyMore1, "eventID2", "eventID"+strconv.Itoa(eventID+1))
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
event, err := c.GetEvent("eventID1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, maxNumberOfMergedEvents, numberOfCalls)
|
||||
require.Equal(t, 1, event.More)
|
||||
}
|
||||
|
||||
var (
|
||||
testEventMessageUpdateUnread = 0
|
||||
|
||||
testEvent = &Event{
|
||||
EventID: "eventID1",
|
||||
Refresh: 0,
|
||||
Messages: []*EventMessage{
|
||||
{
|
||||
EventItem: EventItem{ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==", Action: EventCreate},
|
||||
Created: &Message{
|
||||
ID: "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
|
||||
Subject: "Hey there",
|
||||
},
|
||||
},
|
||||
{
|
||||
EventItem: EventItem{ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==", Action: EventUpdateFlags},
|
||||
Updated: &EventMessageUpdated{
|
||||
ID: "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==",
|
||||
Unread: &testEventMessageUpdateUnread,
|
||||
Time: 1472391377,
|
||||
LabelIDsAdded: []string{ArchiveLabel},
|
||||
LabelIDsRemoved: []string{InboxLabel},
|
||||
},
|
||||
},
|
||||
{
|
||||
EventItem: EventItem{ID: "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==", Action: EventDelete},
|
||||
},
|
||||
},
|
||||
MessageCounts: []*MessagesCount{
|
||||
{
|
||||
LabelID: "0",
|
||||
Total: 19,
|
||||
Unread: 2,
|
||||
},
|
||||
{
|
||||
LabelID: "6",
|
||||
Total: 1,
|
||||
Unread: 0,
|
||||
},
|
||||
},
|
||||
Notices: []string{"Server will be down in 2min because of a NSA attack"},
|
||||
}
|
||||
|
||||
testEventMerged = &Event{
|
||||
EventID: "eventID3",
|
||||
Refresh: 1,
|
||||
Messages: []*EventMessage{
|
||||
{
|
||||
EventItem: EventItem{ID: "msgID1", Action: EventCreate},
|
||||
Created: &Message{
|
||||
ID: "id",
|
||||
Subject: "Hey there",
|
||||
},
|
||||
},
|
||||
{
|
||||
EventItem: EventItem{ID: "msgID2", Action: EventCreate},
|
||||
Created: &Message{
|
||||
ID: "id",
|
||||
Subject: "Hey there again",
|
||||
},
|
||||
},
|
||||
},
|
||||
MessageCounts: []*MessagesCount{
|
||||
{
|
||||
LabelID: "label1",
|
||||
Total: 19,
|
||||
Unread: 2,
|
||||
},
|
||||
{
|
||||
LabelID: "label2",
|
||||
Total: 1,
|
||||
Unread: 0,
|
||||
},
|
||||
{
|
||||
LabelID: "label2",
|
||||
Total: 2,
|
||||
Unread: 1,
|
||||
},
|
||||
{
|
||||
LabelID: "label3",
|
||||
Total: 1,
|
||||
Unread: 0,
|
||||
},
|
||||
},
|
||||
Notices: []string{"Server will be down in 2min because of a NSA attack", "Just kidding lol"},
|
||||
Labels: []*EventLabel{
|
||||
{
|
||||
EventItem: EventItem{
|
||||
ID: "labelID1",
|
||||
Action: 1,
|
||||
},
|
||||
Label: &Label{
|
||||
ID: "id",
|
||||
Name: "Event Label 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
EventItem: EventItem{
|
||||
ID: "labelID2",
|
||||
Action: 1,
|
||||
},
|
||||
Label: &Label{
|
||||
ID: "id",
|
||||
Name: "Event Label 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
User: User{
|
||||
ID: "userID1",
|
||||
Name: "user",
|
||||
UsedSpace: 23456,
|
||||
},
|
||||
Addresses: []*EventAddress{
|
||||
{
|
||||
EventItem: EventItem{
|
||||
ID: "addressID1",
|
||||
Action: 2,
|
||||
},
|
||||
Address: &Address{
|
||||
ID: "id",
|
||||
DisplayName: "address 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
EventItem: EventItem{
|
||||
ID: "addressID2",
|
||||
Action: 2,
|
||||
},
|
||||
Address: &Address{
|
||||
ID: "id",
|
||||
DisplayName: "address 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
testEventBody = `{
|
||||
"EventID": "eventID1",
|
||||
"Refresh": 0,
|
||||
"Messages": [
|
||||
{
|
||||
"ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
|
||||
"Action": 1,
|
||||
"Message": {
|
||||
"ID": "hdI7aIgUO1hFplCIcJHB0jShRVsAzS0AB75wGCaiNVeIHXLmaUnt4eJ8l7c7L6uk4g0ZdXhGWG5gfh6HHgAZnw==",
|
||||
"Subject": "Hey there"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "bSFLAimPSfGz2Kj0aV3l3AyXsof_Vf7sfrrMJ8ifgGJe-f2NG2eLaEGXLytjMhq9wnLMtkoZpO2uBXM4nOVa5g==",
|
||||
"Action": 3,
|
||||
"Message": {
|
||||
"ConversationID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
|
||||
"Unread": 0,
|
||||
"Time": 1472391377,
|
||||
"Location": 6,
|
||||
"LabelIDsAdded": [
|
||||
"6"
|
||||
],
|
||||
"LabelIDsRemoved": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "XRBMBYnSkaEJWtqFACp2kjlNc-7GjzX3SnPcOtWK4PyLG11Nhsg0uxPYjTXoClQfB-EHVDl9gE3w2PVuj93jBg==",
|
||||
"Action": 0
|
||||
}
|
||||
],
|
||||
"Conversations": [
|
||||
{
|
||||
"ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
|
||||
"Action": 1,
|
||||
"Conversation": {
|
||||
"ID": "2oX3EILYRuZ0IRBVlzMg1oV5eazQL67sFIHlcR8bjickPn7K4id4sJZuAB6n0pdtI3hRIVsjCpgWfRm8c_x3IQ==",
|
||||
"Order": 1616,
|
||||
"Subject": "Hey there",
|
||||
"Senders": [
|
||||
{
|
||||
"Address": "apple@protonmail.com",
|
||||
"Name": "apple@protonmail.com"
|
||||
}
|
||||
],
|
||||
"Recipients": [
|
||||
{
|
||||
"Address": "apple@protonmail.com",
|
||||
"Name": "apple@protonmail.com"
|
||||
}
|
||||
],
|
||||
"NumMessages": 1,
|
||||
"NumUnread": 1,
|
||||
"NumAttachments": 0,
|
||||
"ExpirationTime": 0,
|
||||
"TotalSize": 636,
|
||||
"AddressID": "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
|
||||
"LabelIDs": [
|
||||
"0"
|
||||
],
|
||||
"Labels": [
|
||||
{
|
||||
"Count": 1,
|
||||
"NumMessages": 1,
|
||||
"NumUnread": 1,
|
||||
"ID": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Total": {
|
||||
"Locations": [
|
||||
{
|
||||
"Location": 0,
|
||||
"Count": 19
|
||||
},
|
||||
{
|
||||
"Location": 1,
|
||||
"Count": 16
|
||||
},
|
||||
{
|
||||
"Location": 2,
|
||||
"Count": 16
|
||||
},
|
||||
{
|
||||
"Location": 3,
|
||||
"Count": 17
|
||||
},
|
||||
{
|
||||
"Location": 6,
|
||||
"Count": 1
|
||||
}
|
||||
],
|
||||
"Labels": [
|
||||
{
|
||||
"LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
|
||||
"Count": 2
|
||||
},
|
||||
{
|
||||
"LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
|
||||
"Count": 2
|
||||
}
|
||||
],
|
||||
"Starred": 3
|
||||
},
|
||||
"Unread": {
|
||||
"Locations": [
|
||||
{
|
||||
"Location": 0,
|
||||
"Count": 2
|
||||
},
|
||||
{
|
||||
"Location": 1,
|
||||
"Count": 0
|
||||
},
|
||||
{
|
||||
"Location": 2,
|
||||
"Count": 0
|
||||
},
|
||||
{
|
||||
"Location": 3,
|
||||
"Count": 0
|
||||
},
|
||||
{
|
||||
"Location": 6,
|
||||
"Count": 0
|
||||
}
|
||||
],
|
||||
"Labels": [
|
||||
{
|
||||
"LabelID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
|
||||
"Count": 0
|
||||
},
|
||||
{
|
||||
"LabelID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
|
||||
"Count": 0
|
||||
}
|
||||
],
|
||||
"Starred": 0
|
||||
},
|
||||
"MessageCounts": [
|
||||
{
|
||||
"LabelID": "0",
|
||||
"Total": 19,
|
||||
"Unread": 2
|
||||
},
|
||||
{
|
||||
"LabelID": "6",
|
||||
"Total": 1,
|
||||
"Unread": 0
|
||||
}
|
||||
],
|
||||
"ConversationCounts": [
|
||||
{
|
||||
"LabelID": "0",
|
||||
"Total": 19,
|
||||
"Unread": 2
|
||||
},
|
||||
{
|
||||
"LabelID": "6",
|
||||
"Total": 1,
|
||||
"Unread": 0
|
||||
}
|
||||
],
|
||||
"UsedSpace": 7552905,
|
||||
"Notices": ["Server will be down in 2min because of a NSA attack"],
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
testEventBodyMore1 = `{
|
||||
"EventID": "eventID2",
|
||||
"More": 1,
|
||||
"Refresh": 1,
|
||||
"Messages": [
|
||||
{
|
||||
"ID": "msgID1",
|
||||
"Action": 1,
|
||||
"Message": {
|
||||
"ID": "id",
|
||||
"Subject": "Hey there"
|
||||
}
|
||||
}
|
||||
],
|
||||
"MessageCounts": [
|
||||
{
|
||||
"LabelID": "label1",
|
||||
"Total": 19,
|
||||
"Unread": 2
|
||||
},
|
||||
{
|
||||
"LabelID": "label2",
|
||||
"Total": 1,
|
||||
"Unread": 0
|
||||
}
|
||||
],
|
||||
"Labels": [
|
||||
{
|
||||
"ID":"labelID1",
|
||||
"Action":1,
|
||||
"Label":{
|
||||
"ID":"id",
|
||||
"Name":"Event Label 1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"User": {
|
||||
"ID": "userID1",
|
||||
"Name": "user",
|
||||
"UsedSpace": 12345
|
||||
},
|
||||
"Addresses": [
|
||||
{
|
||||
"ID": "addressID1",
|
||||
"Action": 2,
|
||||
"Address": {
|
||||
"ID": "id",
|
||||
"DisplayName": "address 1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Notices": ["Server will be down in 2min because of a NSA attack"]
|
||||
}
|
||||
`
|
||||
|
||||
testEventBodyMore2 = `{
|
||||
"EventID": "eventID3",
|
||||
"Refresh": 0,
|
||||
"Messages": [
|
||||
{
|
||||
"ID": "msgID2",
|
||||
"Action": 1,
|
||||
"Message": {
|
||||
"ID": "id",
|
||||
"Subject": "Hey there again"
|
||||
}
|
||||
}
|
||||
],
|
||||
"MessageCounts": [
|
||||
{
|
||||
"LabelID": "label2",
|
||||
"Total": 2,
|
||||
"Unread": 1
|
||||
},
|
||||
{
|
||||
"LabelID": "label3",
|
||||
"Total": 1,
|
||||
"Unread": 0
|
||||
}
|
||||
],
|
||||
"Labels": [
|
||||
{
|
||||
"ID":"labelID2",
|
||||
"Action":1,
|
||||
"Label":{
|
||||
"ID":"id",
|
||||
"Name":"Event Label 2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"User": {
|
||||
"ID": "userID1",
|
||||
"Name": "user",
|
||||
"UsedSpace": 23456
|
||||
},
|
||||
"Addresses": [
|
||||
{
|
||||
"ID": "addressID2",
|
||||
"Action": 2,
|
||||
"Address": {
|
||||
"ID": "id",
|
||||
"DisplayName": "address 2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Notices": ["Just kidding lol"]
|
||||
}
|
||||
`
|
||||
)
|
||||
157
pkg/pmapi/import.go
Normal file
157
pkg/pmapi/import.go
Normal file
@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Import errors.
|
||||
const (
|
||||
ImportMessageTooLarge = 36022
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ImportRes is a response to an import request.
|
||||
type ImportRes struct {
|
||||
Res
|
||||
|
||||
Responses []struct {
|
||||
Name string
|
||||
Response struct {
|
||||
Res
|
||||
MessageID string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ImportMsgRes is a response to a single message import request.
|
||||
type ImportMsgRes struct {
|
||||
// The error encountered while importing the message, if any.
|
||||
Error error
|
||||
// The newly created message ID.
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// Import imports messages to the user's account.
|
||||
func (c *Client) Import(reqs []*ImportMsgReq) (resps []*ImportMsgRes, err error) {
|
||||
importReq := &ImportReq{Messages: reqs}
|
||||
|
||||
req, w, err := NewMultipartRequest("POST", "/import")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
return resps, err
|
||||
}
|
||||
155
pkg/pmapi/import_test.go
Normal file
155
pkg/pmapi/import_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testImportReqs = []*ImportMsgReq{
|
||||
{
|
||||
AddressID: "QMJs2dzTx7uqpH5PNgIzjULywU4gO9uMBhEMVFOAVJOoUml54gC0CCHtW9qYwzH-zYbZwMv3MFYncPjW1Usq7Q==",
|
||||
Body: []byte("Hello World!"),
|
||||
Unread: 0,
|
||||
Flags: FlagReceived | FlagImported,
|
||||
LabelIDs: []string{ArchiveLabel},
|
||||
},
|
||||
}
|
||||
|
||||
const testImportBody = `{
|
||||
"Code": 1001,
|
||||
"Responses": [{
|
||||
"Name": "0",
|
||||
"Response": {"Code": 1000, "MessageID": "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg=="}
|
||||
}]
|
||||
}`
|
||||
|
||||
var testImportRes = &ImportMsgRes{
|
||||
Error: nil,
|
||||
MessageID: "UKjSNz95KubYjrYmfbv1mbIfGxzY6D64mmHmVpWhkeEau-u0PIS4ru5IFMHgX6WjKpWYKCht3oiOtL5-wZChNg==",
|
||||
}
|
||||
|
||||
func TestClient_Import(t *testing.T) { // nolint[funlen]
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/import"))
|
||||
|
||||
contentType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Error("Expected no error while parsing request content type, got:", err)
|
||||
}
|
||||
if contentType != "multipart/form-data" {
|
||||
t.Errorf("Invalid request content type: expected %v but got %v", "multipart/form-data", contentType)
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||
|
||||
// First 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 := mime.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 = mime.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"] != "0" {
|
||||
t.Errorf("Invalid part name: expected %v but got %v", "0", params["name"])
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(p)
|
||||
if err != nil {
|
||||
t.Error("Expected no error while reading second part body, got:", err)
|
||||
}
|
||||
|
||||
if string(b) != string(testImportReqs[0].Body) {
|
||||
t.Errorf("Invalid message body: expected %v but got %v", string(testImportReqs[0].Body), string(b))
|
||||
}
|
||||
|
||||
// No more parts.
|
||||
_, err = mr.NextPart()
|
||||
if err != io.EOF {
|
||||
t.Error("Expected no more parts but error was not EOF, got:", err)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testImportBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
imported, err := c.Import(testImportReqs)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while importing, got:", err)
|
||||
}
|
||||
|
||||
if len(imported) != 1 {
|
||||
t.Fatalf("Expected exactly one imported message, got %v", len(imported))
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(testImportRes, imported[0]) {
|
||||
t.Errorf("Invalid response for imported message: expected %+v but got %+v", testImportRes, imported[0])
|
||||
}
|
||||
}
|
||||
138
pkg/pmapi/key.go
Normal file
138
pkg/pmapi/key.go
Normal file
@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
)
|
||||
|
||||
// Flags
|
||||
const (
|
||||
UseToVerifyFlag = 1 << iota
|
||||
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]*pmcrypto.KeyRing, err error) {
|
||||
if len(emails) == 0 {
|
||||
err = fmt.Errorf("pmapi: cannot get public keys: no email address provided")
|
||||
return
|
||||
}
|
||||
keys = map[string]*pmcrypto.KeyRing{}
|
||||
|
||||
for _, email := range emails {
|
||||
email = url.QueryEscape(email)
|
||||
|
||||
var req *http.Request
|
||||
if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res PublicKeyRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, key := range res.Keys {
|
||||
if key.Flags&UseToEncryptFlag == UseToEncryptFlag {
|
||||
var kr *pmcrypto.KeyRing
|
||||
if kr, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(key.PublicKey)); err != nil {
|
||||
return
|
||||
}
|
||||
keys[email] = kr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys, err
|
||||
}
|
||||
|
||||
const (
|
||||
RecipientInternal = 1
|
||||
RecipientExternal = 2
|
||||
)
|
||||
|
||||
// GetPublicKeysForEmail returns all sending public keys for the given email address.
|
||||
func (c *Client) GetPublicKeysForEmail(email string) (keys []PublicKey, internal bool, err error) {
|
||||
email = url.QueryEscape(email)
|
||||
|
||||
var req *http.Request
|
||||
if req, err = NewRequest("GET", "/keys?Email="+email, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res PublicKeyRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
internal = res.RecipientType == RecipientInternal
|
||||
|
||||
for _, key := range res.Keys {
|
||||
if key.Flags&UseToEncryptFlag == UseToEncryptFlag {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// KeySalt contains id and salt for key.
|
||||
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 = NewRequest("GET", "/keys/salts", nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res KeySaltRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
keySalts = res.KeySalts
|
||||
|
||||
return
|
||||
}
|
||||
295
pkg/pmapi/keyring.go
Normal file
295
pkg/pmapi/keyring.go
Normal file
@ -0,0 +1,295 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"sync"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
)
|
||||
|
||||
// clearableKey is a region of memory intended to hold a private key and which can be securely
|
||||
// cleared by calling clear().
|
||||
type clearableKey []byte
|
||||
|
||||
// UnmarshalJSON Removes quotation and unescapes CR, LF.
|
||||
func (pk *clearableKey) UnmarshalJSON(b []byte) (err error) {
|
||||
b = bytes.Trim(b, "\"")
|
||||
b = bytes.ReplaceAll(b, []byte("\\n"), []byte("\n"))
|
||||
b = bytes.ReplaceAll(b, []byte("\\r"), []byte("\r"))
|
||||
*pk = b
|
||||
return
|
||||
}
|
||||
|
||||
// clear irreversibly destroys the full range of `clearableKey` by filling it with zeros to ensure
|
||||
// nobody can see what was in there (e.g. while waiting for the garbage collector to clean it up).
|
||||
func (pk *clearableKey) clear() {
|
||||
for b := range *pk {
|
||||
(*pk)[b] = 0
|
||||
}
|
||||
}
|
||||
|
||||
type PMKey struct {
|
||||
ID string
|
||||
Version int
|
||||
Flags int
|
||||
Fingerprint string
|
||||
Primary int
|
||||
Token *string `json:",omitempty"`
|
||||
Signature *string `json:",omitempty"`
|
||||
}
|
||||
|
||||
type PMKeys struct {
|
||||
Keys []PMKey
|
||||
KeyRing *pmcrypto.KeyRing
|
||||
}
|
||||
|
||||
func (k *PMKeys) UnmarshalJSON(b []byte) (err error) {
|
||||
var rawKeys []struct {
|
||||
PMKey
|
||||
PrivateKey clearableKey
|
||||
}
|
||||
if err = json.Unmarshal(b, &rawKeys); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
k.KeyRing = &pmcrypto.KeyRing{}
|
||||
for _, rawKey := range rawKeys {
|
||||
err = k.KeyRing.ReadFrom(bytes.NewReader(rawKey.PrivateKey), true)
|
||||
rawKey.PrivateKey.clear()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
k.Keys = append(k.Keys, rawKey.PMKey)
|
||||
}
|
||||
if len(k.Keys) > 0 {
|
||||
k.KeyRing.FirstKeyID = k.Keys[0].ID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// unlockKeyRing tries to unlock them with the provided keyRing using the token
|
||||
// and if the token is not available it will use passphrase. It will not fail
|
||||
// if keyring contains at least one unlocked private key.
|
||||
func (k *PMKeys) unlockKeyRing(userKeyring *pmcrypto.KeyRing, passphrase []byte, locker sync.Locker) (err error) {
|
||||
locker.Lock()
|
||||
defer locker.Unlock()
|
||||
|
||||
if k == nil {
|
||||
err = errors.New("keys is a nil object")
|
||||
return
|
||||
}
|
||||
|
||||
for _, key := range k.Keys {
|
||||
if key.Token == nil || key.Signature == nil {
|
||||
if err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, passphrase); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
message, err := pmcrypto.NewPGPMessageFromArmored(*key.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signature, err := pmcrypto.NewPGPSignatureFromArmored(*key.Signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if userKeyring == nil {
|
||||
return errors.New("userkey required to decrypt tokens but wasn't provided")
|
||||
}
|
||||
token, err := userKeyring.Decrypt(message, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = userKeyring.VerifyDetached(token, signature, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = unlockKeyRingNoErrorWhenAlreadyUnlocked(k.KeyRing, token.GetBinary())
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrong token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type unlockError struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (err *unlockError) Error() string {
|
||||
return "Invalid mailbox password (" + err.error.Error() + ")"
|
||||
}
|
||||
|
||||
// IsUnlockError checks whether the error is due to failure to unlock (which is represented by an unexported type).
|
||||
func IsUnlockError(err error) bool {
|
||||
_, ok := err.(*unlockError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func unlockKeyRingNoErrorWhenAlreadyUnlocked(kr *pmcrypto.KeyRing, passphrase []byte) (err error) {
|
||||
if err = kr.Unlock(passphrase); err != nil {
|
||||
// Do not fail if it has already unlocked keys.
|
||||
hasUnlockedKey := false
|
||||
for _, e := range kr.GetEntities() {
|
||||
if e.PrivateKey != nil && !e.PrivateKey.Encrypted {
|
||||
hasUnlockedKey = true
|
||||
break
|
||||
}
|
||||
for _, se := range e.Subkeys {
|
||||
if se.PrivateKey != nil && (!se.Sig.FlagsValid || se.Sig.FlagEncryptStorage || se.Sig.FlagEncryptCommunications) && !e.PrivateKey.Encrypted {
|
||||
hasUnlockedKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasUnlockedKey {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUnlockedKey {
|
||||
err = &unlockError{err}
|
||||
return
|
||||
}
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ErrNoKeyringAvailable represents an error caused by a keyring being nil or having no entities.
|
||||
var ErrNoKeyringAvailable = errors.New("no keyring available")
|
||||
|
||||
func (c *Client) encrypt(plain string, signer *pmcrypto.KeyRing) (armored string, err error) {
|
||||
return encrypt(c.kr, plain, signer)
|
||||
}
|
||||
|
||||
func encrypt(encrypter *pmcrypto.KeyRing, plain string, signer *pmcrypto.KeyRing) (armored string, err error) {
|
||||
if encrypter == nil || encrypter.FirstKey() == nil {
|
||||
return "", ErrNoKeyringAvailable
|
||||
}
|
||||
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
|
||||
// We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones).
|
||||
pgpMessage, err := encrypter.FirstKey().Encrypt(plainMessage, signer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return pgpMessage.GetArmored()
|
||||
}
|
||||
|
||||
func (c *Client) decrypt(armored string) (plain string, err error) {
|
||||
return decrypt(c.kr, armored)
|
||||
}
|
||||
|
||||
func decrypt(decrypter *pmcrypto.KeyRing, armored string) (plainBody string, err error) {
|
||||
if decrypter == nil {
|
||||
return "", ErrNoKeyringAvailable
|
||||
}
|
||||
pgpMessage, err := pmcrypto.NewPGPMessageFromArmored(armored)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
plainMessage, err := decrypter.Decrypt(pgpMessage, nil, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return plainMessage.GetString(), nil
|
||||
}
|
||||
|
||||
func (c *Client) sign(plain string) (armoredSignature string, err error) {
|
||||
if c.kr == nil {
|
||||
return "", ErrNoKeyringAvailable
|
||||
}
|
||||
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
|
||||
pgpSignature, err := c.kr.SignDetached(plainMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return pgpSignature.GetArmored()
|
||||
}
|
||||
|
||||
func (c *Client) verify(plain, amroredSignature string) (err error) {
|
||||
plainMessage := pmcrypto.NewPlainMessageFromString(plain)
|
||||
pgpSignature, err := pmcrypto.NewPGPSignatureFromArmored(amroredSignature)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
verifyTime := int64(0) // By default it will use current timestamp.
|
||||
return c.kr.VerifyDetached(plainMessage, pgpSignature, verifyTime)
|
||||
}
|
||||
|
||||
func encryptAttachment(kr *pmcrypto.KeyRing, data io.Reader, filename string) (encrypted io.Reader, err error) {
|
||||
if kr == nil || kr.FirstKey() == nil {
|
||||
return nil, ErrNoKeyringAvailable
|
||||
}
|
||||
dataBytes, err := ioutil.ReadAll(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
plainMessage := pmcrypto.NewPlainMessage(dataBytes)
|
||||
// We use only primary key to encrypt the message. Our keyring contains all keys (primary, old and deacivated ones).
|
||||
pgpSplitMessage, err := kr.FirstKey().EncryptAttachment(plainMessage, filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
packets := append(pgpSplitMessage.KeyPacket, pgpSplitMessage.DataPacket...)
|
||||
return bytes.NewReader(packets), nil
|
||||
}
|
||||
|
||||
func decryptAttachment(kr *pmcrypto.KeyRing, keyPackets []byte, data io.Reader) (decrypted io.Reader, err error) {
|
||||
if kr == nil {
|
||||
return nil, ErrNoKeyringAvailable
|
||||
}
|
||||
dataBytes, err := ioutil.ReadAll(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pgpSplitMessage := pmcrypto.NewPGPSplitMessage(keyPackets, dataBytes)
|
||||
plainMessage, err := kr.DecryptAttachment(pgpSplitMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return plainMessage.NewReader(), nil
|
||||
}
|
||||
|
||||
func signAttachment(encrypter *pmcrypto.KeyRing, data io.Reader) (signature io.Reader, err error) {
|
||||
if encrypter == nil {
|
||||
return nil, ErrNoKeyringAvailable
|
||||
}
|
||||
dataBytes, err := ioutil.ReadAll(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
plainMessage := pmcrypto.NewPlainMessage(dataBytes)
|
||||
sig, err := encrypter.SignDetached(plainMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return bytes.NewReader(sig.GetBinary()), nil
|
||||
}
|
||||
94
pkg/pmapi/keyring_test.go
Normal file
94
pkg/pmapi/keyring_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func loadPMKeys(jsonKeys string) (keys *PMKeys) {
|
||||
_ = json.Unmarshal([]byte(jsonKeys), &keys)
|
||||
return
|
||||
}
|
||||
|
||||
func TestPMKeys_GetKeyRingAndUnlock(t *testing.T) {
|
||||
addrKeysWithTokens := loadPMKeys(readTestFile("keyring_addressKeysWithTokens_JSON", false))
|
||||
addrKeysWithoutTokens := loadPMKeys(readTestFile("keyring_addressKeysWithoutTokens_JSON", false))
|
||||
addrKeysPrimaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysPrimaryHasToken_JSON", false))
|
||||
addrKeysSecondaryHasToken := loadPMKeys(readTestFile("keyring_addressKeysSecondaryHasToken_JSON", false))
|
||||
|
||||
userKey, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(readTestFile("keyring_userKey", false)))
|
||||
assert.NoError(t, err, "Expected not to receive an error unlocking user key")
|
||||
|
||||
type args struct {
|
||||
userKeyring *pmcrypto.KeyRing
|
||||
passphrase []byte
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
keys *PMKeys
|
||||
args args
|
||||
}{
|
||||
{
|
||||
name: "AddressKeys locked with tokens",
|
||||
keys: addrKeysWithTokens,
|
||||
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
|
||||
},
|
||||
{
|
||||
name: "AddressKeys locked with passphrase, not tokens",
|
||||
keys: addrKeysWithoutTokens,
|
||||
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
|
||||
},
|
||||
{
|
||||
name: "AddressKeys, primary locked with token, secondary with passphrase",
|
||||
keys: addrKeysPrimaryHasToken,
|
||||
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
|
||||
},
|
||||
{
|
||||
name: "AddressKeys, primary locked with passphrase, secondary with token",
|
||||
keys: addrKeysSecondaryHasToken,
|
||||
args: args{userKeyring: userKey, passphrase: []byte("testpassphrase")},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempLocker := &sync.Mutex{}
|
||||
|
||||
err := tt.keys.unlockKeyRing(tt.args.userKeyring, tt.args.passphrase, tempLocker) // nolint[scopelint]
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// assert at least one key has been decrypted
|
||||
atLeastOneDecrypted := false
|
||||
for _, e := range tt.keys.KeyRing.GetEntities() { // nolint[scopelint]
|
||||
if !e.PrivateKey.Encrypted {
|
||||
atLeastOneDecrypted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, atLeastOneDecrypted)
|
||||
})
|
||||
}
|
||||
}
|
||||
177
pkg/pmapi/labels.go
Normal file
177
pkg/pmapi/labels.go
Normal file
@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2020 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"
|
||||
|
||||
// System labels
|
||||
const (
|
||||
InboxLabel = "0"
|
||||
AllDraftsLabel = "1"
|
||||
AllSentLabel = "2"
|
||||
TrashLabel = "3"
|
||||
SpamLabel = "4"
|
||||
AllMailLabel = "5"
|
||||
ArchiveLabel = "6"
|
||||
SentLabel = "7"
|
||||
DraftLabel = "8"
|
||||
StarredLabel = "10"
|
||||
|
||||
LabelTypeMailbox = 1
|
||||
LabelTypeContactGroup = 2
|
||||
)
|
||||
|
||||
// IsSystemLabel checks if a label is a pre-defined system label.
|
||||
func IsSystemLabel(label string) bool {
|
||||
switch label {
|
||||
case InboxLabel, DraftLabel, SentLabel, TrashLabel, SpamLabel, ArchiveLabel, StarredLabel, AllMailLabel, AllSentLabel, AllDraftsLabel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LabelColors provides the RGB values of the available label colors.
|
||||
var LabelColors = []string{ //nolint[gochecknoglobals]
|
||||
"#7272a7",
|
||||
"#cf5858",
|
||||
"#c26cc7",
|
||||
"#7569d1",
|
||||
"#69a9d1",
|
||||
"#5ec7b7",
|
||||
"#72bb75",
|
||||
"#c3d261",
|
||||
"#e6c04c",
|
||||
"#e6984c",
|
||||
"#8989ac",
|
||||
"#cf7e7e",
|
||||
"#c793ca",
|
||||
"#9b94d1",
|
||||
"#a8c4d5",
|
||||
"#97c9c1",
|
||||
"#9db99f",
|
||||
"#c6cd97",
|
||||
"#e7d292",
|
||||
"#dfb286",
|
||||
}
|
||||
|
||||
type LabelAction int
|
||||
|
||||
const (
|
||||
RemoveLabel LabelAction = iota
|
||||
AddLabel
|
||||
)
|
||||
|
||||
// Label for message.
|
||||
type Label struct {
|
||||
ID string
|
||||
Name string
|
||||
Color string
|
||||
Order int `json:",omitempty"`
|
||||
Display int // Not used for now, leave it empty.
|
||||
Exclusive int
|
||||
Type int
|
||||
Notify int
|
||||
}
|
||||
|
||||
type LabelListRes struct {
|
||||
Res
|
||||
Labels []*Label
|
||||
}
|
||||
|
||||
func (c *Client) ListLabels() (labels []*Label, err error) {
|
||||
return c.ListLabelType(LabelTypeMailbox)
|
||||
}
|
||||
|
||||
func (c *Client) ListContactGroups() (labels []*Label, err error) {
|
||||
return c.ListLabelType(LabelTypeContactGroup)
|
||||
}
|
||||
|
||||
// ListLabelType lists all labels created by the user.
|
||||
func (c *Client) ListLabelType(labelType int) (labels []*Label, err error) {
|
||||
req, err := NewRequest("GET", fmt.Sprintf("/labels?%d", labelType), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res LabelListRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
labels, err = res.Labels, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
labelReq := &LabelReq{label}
|
||||
req, err := NewJSONRequest("POST", "/labels", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res LabelRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
created, err = res.Label, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateLabel updates a label.
|
||||
func (c *Client) UpdateLabel(label *Label) (updated *Label, err error) {
|
||||
labelReq := &LabelReq{label}
|
||||
req, err := NewJSONRequest("PUT", "/labels/"+label.ID, labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res LabelRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
updated, err = res.Label, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteLabel deletes a label.
|
||||
func (c *Client) DeleteLabel(id string) (err error) {
|
||||
req, err := NewRequest("DELETE", "/labels/"+id, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
186
pkg/pmapi/labels_test.go
Normal file
186
pkg/pmapi/labels_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testLabelsBody = `{
|
||||
"Labels": [
|
||||
{
|
||||
"ID": "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==",
|
||||
"Name": "CroutonMail is awesome :)",
|
||||
"Color": "#7272a7",
|
||||
"Display": 0,
|
||||
"Order": 1,
|
||||
"Type": 1
|
||||
},
|
||||
{
|
||||
"ID": "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==",
|
||||
"Name": "Royal sausage",
|
||||
"Color": "#cf5858",
|
||||
"Display": 1,
|
||||
"Order": 2,
|
||||
"Type": 1
|
||||
}
|
||||
],
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
var testLabels = []*Label{
|
||||
{ID: "LLz8ysmVxwr4dF6mWpClePT0SpSWOEvzTdq17RydSl4ndMckvY1K63HeXDzn03BJQwKYvgf-eWT8Qfd9WVuIEQ==", Name: "CroutonMail is awesome :)", Color: "#7272a7", Order: 1, Display: 0, Type: LabelTypeMailbox},
|
||||
{ID: "BvbqbySUPo9uWW_eR8tLA13NUsQMz3P4Zhw4UnpvrKqURnrHlE6L2Au0nplHfHlVXFgGz4L4hJ9-BYllOL-L5g==", Name: "Royal sausage", Color: "#cf5858", Order: 2, Display: 1, Type: LabelTypeMailbox},
|
||||
}
|
||||
|
||||
var testLabelReq = LabelReq{&Label{
|
||||
Name: "sava",
|
||||
Color: "#c26cc7",
|
||||
Display: 1,
|
||||
}}
|
||||
|
||||
const testCreateLabelBody = `{
|
||||
"Label": {
|
||||
"ID": "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==",
|
||||
"Name": "sava",
|
||||
"Color": "#c26cc7",
|
||||
"Display": 1,
|
||||
"Order": 3,
|
||||
"Type": 1
|
||||
},
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
var testLabelCreated = &Label{
|
||||
ID: "otkpEZzG--8dMXvwyLXLQWB72hhBhNGzINjH14rUDfywvOyeN01cDxDrS3Koifxf6asA7Xcwtldm0r_MCmWiAQ==",
|
||||
Name: "sava",
|
||||
Color: "#c26cc7",
|
||||
Order: 3,
|
||||
Display: 1,
|
||||
Type: LabelTypeMailbox,
|
||||
}
|
||||
|
||||
const testDeleteLabelBody = `{
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
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"))
|
||||
|
||||
fmt.Fprint(w, testLabelsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
labels, err := c.ListLabels()
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while listing labels, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(labels, testLabels) {
|
||||
for i, l := range testLabels {
|
||||
t.Errorf("expected %d: %#v\n", i, l)
|
||||
}
|
||||
for i, l := range labels {
|
||||
t.Errorf("got %d: %#v\n", i, l)
|
||||
}
|
||||
t.Fatalf("Not same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateLabel(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "POST", "/labels"))
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
_, err := body.ReadFrom(r.Body)
|
||||
Ok(t, err)
|
||||
|
||||
if bytes.Contains(body.Bytes(), []byte("Order")) {
|
||||
t.Fatal("Body contains `Order`: ", body.String())
|
||||
}
|
||||
|
||||
var labelReq LabelReq
|
||||
if err := json.NewDecoder(body).Decode(&labelReq); err != nil {
|
||||
t.Error("Expecting no error while reading request body, got:", err)
|
||||
}
|
||||
if !reflect.DeepEqual(testLabelReq.Label, labelReq.Label) {
|
||||
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelReq.Label, labelReq.Label)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testCreateLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
created, err := c.CreateLabel(testLabelReq.Label)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while creating label, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(created, testLabelCreated) {
|
||||
t.Fatalf("Invalid created label: expected %+v, got %+v", testLabelCreated, created)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
var labelReq LabelReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&labelReq); err != nil {
|
||||
t.Error("Expecting no error while reading request body, got:", err)
|
||||
}
|
||||
if !reflect.DeepEqual(testLabelCreated, labelReq.Label) {
|
||||
t.Errorf("Invalid label request: expected %+v but got %+v", testLabelCreated, labelReq.Label)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, testCreateLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
updated, err := c.UpdateLabel(testLabelCreated)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while updating label, got:", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(updated, testLabelCreated) {
|
||||
t.Fatalf("Invalid updated label: expected %+v, got %+v", testLabelCreated, updated)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
fmt.Fprint(w, testDeleteLabelBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.DeleteLabel(testLabelCreated.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while deleting label, got:", err)
|
||||
}
|
||||
}
|
||||
810
pkg/pmapi/messages.go
Normal file
810
pkg/pmapi/messages.go
Normal file
@ -0,0 +1,810 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"golang.org/x/crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
// Header types.
|
||||
const (
|
||||
MessageHeader = "-----BEGIN PGP MESSAGE-----"
|
||||
MessageTail = "-----END PGP MESSAGE-----"
|
||||
MessageHeaderLegacy = "---BEGIN ENCRYPTED MESSAGE---"
|
||||
MessageTailLegacy = "---END ENCRYPTED MESSAGE---"
|
||||
RandomKeyHeader = "---BEGIN ENCRYPTED RANDOM KEY---"
|
||||
RandomKeyTail = "---END ENCRYPTED RANDOM KEY---"
|
||||
)
|
||||
|
||||
// Sort types.
|
||||
const (
|
||||
SortByTo = "To"
|
||||
SortByFrom = "From"
|
||||
SortBySubject = "Subject"
|
||||
SortBySize = "Size"
|
||||
SortByTime = "Time"
|
||||
SortByID = "ID"
|
||||
SortDesc = true
|
||||
SortAsc = false
|
||||
)
|
||||
|
||||
// Message actions.
|
||||
const (
|
||||
ActionReply = 0
|
||||
ActionReplyAll = 1
|
||||
ActionForward = 2
|
||||
)
|
||||
|
||||
// Message flag definitions.
|
||||
const (
|
||||
FlagReceived = 1
|
||||
FlagSent = 2
|
||||
FlagInternal = 4
|
||||
FlagE2E = 8
|
||||
FlagAuto = 16
|
||||
FlagReplied = 32
|
||||
FlagRepliedAll = 64
|
||||
FlagForwarded = 128
|
||||
|
||||
FlagAutoreplied = 256
|
||||
FlagImported = 512
|
||||
FlagOpened = 1024
|
||||
FlagReceiptSent = 2048
|
||||
)
|
||||
|
||||
// Draft flags.
|
||||
const (
|
||||
FlagReceiptRequest = 1 << 16
|
||||
FlagPublicKey = 1 << 17
|
||||
FlagSign = 1 << 18
|
||||
)
|
||||
|
||||
// Spam flags.
|
||||
const (
|
||||
FlagSpfFail = 1 << 24
|
||||
FlagDkimFail = 1 << 25
|
||||
FlagDmarcFail = 1 << 26
|
||||
FlagHamManual = 1 << 27
|
||||
FlagSpamAuto = 1 << 28
|
||||
FlagSpamManual = 1 << 29
|
||||
FlagPhishingAuto = 1 << 30
|
||||
FlagPhishingManual = 1 << 31
|
||||
)
|
||||
|
||||
// Message flag masks.
|
||||
const (
|
||||
FlagMaskGeneral = 4095
|
||||
FlagMaskDraft = FlagReceiptRequest * 7
|
||||
FlagMaskSpam = FlagSpfFail * 255
|
||||
FlagMask = FlagMaskGeneral | FlagMaskDraft | FlagMaskSpam
|
||||
)
|
||||
|
||||
// INTERNAL, AUTO are immutable. E2E is immutable except for drafts on send.
|
||||
const (
|
||||
FlagMaskAdd = 4067 + (16777216 * 168)
|
||||
)
|
||||
|
||||
// Content types.
|
||||
const (
|
||||
ContentTypeMultipartMixed = "multipart/mixed"
|
||||
ContentTypeMultipartEncrypted = "multipart/encrypted"
|
||||
ContentTypePlainText = "text/plain"
|
||||
ContentTypeHTML = "text/html"
|
||||
)
|
||||
|
||||
// LabelsOperation is the operation to apply to labels.
|
||||
type LabelsOperation int
|
||||
|
||||
const (
|
||||
KeepLabels LabelsOperation = iota // Do nothing.
|
||||
ReplaceLabels // Replace current labels with new ones.
|
||||
AddLabels // Add new labels to current ones.
|
||||
RemoveLabels // Remove specified labels from current ones.
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeInbox int = iota
|
||||
MessageTypeDraft
|
||||
MessageTypeSent
|
||||
MessageTypeInboxAndSent
|
||||
)
|
||||
|
||||
// Due to API limitations, we shouldn't make requests with more than 100 message IDs at a time.
|
||||
const messageIDPageSize = 100
|
||||
|
||||
// Message structure.
|
||||
type Message struct {
|
||||
ID string `json:",omitempty"`
|
||||
Order int64 `json:",omitempty"`
|
||||
ConversationID string `json:",omitempty"` // only filter
|
||||
Subject string
|
||||
Unread int
|
||||
Type int
|
||||
Flags int64
|
||||
Sender *mail.Address
|
||||
ReplyTo *mail.Address `json:",omitempty"`
|
||||
ReplyTos []*mail.Address `json:",omitempty"`
|
||||
ToList []*mail.Address
|
||||
CCList []*mail.Address
|
||||
BCCList []*mail.Address
|
||||
Time int64 // Unix time
|
||||
Size int64
|
||||
NumAttachments int
|
||||
ExpirationTime int64 // Unix time
|
||||
SpamScore int
|
||||
AddressID string
|
||||
Body string `json:",omitempty"`
|
||||
Attachments []*Attachment
|
||||
LabelIDs []string
|
||||
ExternalID string
|
||||
Header mail.Header
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
// NewMessage initializes a new message.
|
||||
func NewMessage() *Message {
|
||||
return &Message{
|
||||
ToList: []*mail.Address{},
|
||||
CCList: []*mail.Address{},
|
||||
BCCList: []*mail.Address{},
|
||||
Attachments: []*Attachment{},
|
||||
LabelIDs: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Define a new type to prevent MarshalJSON/UnmarshalJSON infinite loops.
|
||||
type message Message
|
||||
|
||||
type rawMessage struct {
|
||||
message
|
||||
|
||||
Header string `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (m *Message) MarshalJSON() ([]byte, error) {
|
||||
var raw rawMessage
|
||||
raw.message = message(*m)
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
_ = http.Header(m.Header).Write(b)
|
||||
raw.Header = b.String()
|
||||
|
||||
return json.Marshal(&raw)
|
||||
}
|
||||
|
||||
func (m *Message) UnmarshalJSON(b []byte) error {
|
||||
var raw rawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*m = Message(raw.message)
|
||||
|
||||
if raw.Header != "" && raw.Header != "(No Header)" {
|
||||
msg, err := mail.ReadMessage(strings.NewReader(raw.Header + "\r\n\r\n"))
|
||||
if err == nil {
|
||||
m.Header = msg.Header
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Message) IsBodyEncrypted() bool {
|
||||
trimmedBody := strings.TrimSpace(m.Body)
|
||||
return strings.HasPrefix(trimmedBody, MessageHeader) &&
|
||||
strings.HasSuffix(trimmedBody, MessageTail)
|
||||
}
|
||||
|
||||
func (m *Message) IsLegacyMessage() bool {
|
||||
return strings.Contains(m.Body, RandomKeyHeader) &&
|
||||
strings.Contains(m.Body, RandomKeyTail) &&
|
||||
strings.Contains(m.Body, MessageHeaderLegacy) &&
|
||||
strings.Contains(m.Body, MessageTailLegacy) &&
|
||||
strings.Contains(m.Body, MessageHeader) &&
|
||||
strings.Contains(m.Body, MessageTail)
|
||||
}
|
||||
|
||||
func (m *Message) Decrypt(kr *pmcrypto.KeyRing) (err error) {
|
||||
if m.IsLegacyMessage() {
|
||||
return m.DecryptLegacy(kr)
|
||||
}
|
||||
|
||||
if !m.IsBodyEncrypted() {
|
||||
return
|
||||
}
|
||||
|
||||
armored := strings.TrimSpace(m.Body)
|
||||
body, err := decrypt(kr, armored)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.Body = body
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Message) DecryptLegacy(kr *pmcrypto.KeyRing) (err error) {
|
||||
randomKeyStart := strings.Index(m.Body, RandomKeyHeader) + len(RandomKeyHeader)
|
||||
randomKeyEnd := strings.Index(m.Body, RandomKeyTail)
|
||||
randomKey := m.Body[randomKeyStart:randomKeyEnd]
|
||||
|
||||
signedKey, err := decrypt(kr, strings.TrimSpace(randomKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bytesKey, err := decodeBase64UTF8(signedKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
messageStart := strings.Index(m.Body, MessageHeaderLegacy) + len(MessageHeaderLegacy)
|
||||
messageEnd := strings.Index(m.Body, MessageTailLegacy)
|
||||
message := m.Body[messageStart:messageEnd]
|
||||
bytesMessage, err := decodeBase64UTF8(message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(bytesKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := make([]byte, block.BlockSize()+2)
|
||||
bytesMessageReader := bytes.NewReader(bytesMessage)
|
||||
|
||||
_, err = io.ReadFull(bytesMessageReader, prefix)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s := packet.NewOCFBDecrypter(block, prefix, packet.OCFBResync)
|
||||
if s == nil {
|
||||
err = errors.New("pmapi: incorrect key for legacy decryption")
|
||||
return
|
||||
}
|
||||
|
||||
reader := cipher.StreamReader{S: s, R: bytesMessageReader}
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(reader)
|
||||
plaintextBytes := buf.Bytes()
|
||||
|
||||
plaintext := ""
|
||||
for i := 0; i < len(plaintextBytes); i++ {
|
||||
plaintext += string(plaintextBytes[i])
|
||||
}
|
||||
bytesPlaintext, err := decodeBase64UTF8(plaintext)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.Body = string(bytesPlaintext)
|
||||
return err
|
||||
}
|
||||
|
||||
func decodeBase64UTF8(input string) (output []byte, err error) {
|
||||
input = strings.TrimSpace(input)
|
||||
decodedMessage, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
utf8DecodedMessage := []rune(string(decodedMessage))
|
||||
output = make([]byte, len(utf8DecodedMessage))
|
||||
for i := 0; i < len(utf8DecodedMessage); i++ {
|
||||
output[i] = byte(int(utf8DecodedMessage[i]))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Message) Encrypt(encrypter, signer *pmcrypto.KeyRing) (err error) {
|
||||
if m.IsBodyEncrypted() {
|
||||
err = errors.New("pmapi: trying to encrypt an already encrypted message")
|
||||
return
|
||||
}
|
||||
|
||||
m.Body, err = encrypt(encrypter, m.Body, signer)
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Message) Has(flag int64) bool {
|
||||
return (m.Flags & flag) == flag
|
||||
}
|
||||
|
||||
// MessagesCount contains message counts for one label.
|
||||
type MessagesCount struct {
|
||||
LabelID string
|
||||
Total int
|
||||
Unread int
|
||||
}
|
||||
|
||||
// MessagesFilter contains fields to filter messages.
|
||||
type MessagesFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Limit int
|
||||
LabelID string
|
||||
Sort string // Time by default (Time, To, From, Subject, Size).
|
||||
Desc *bool
|
||||
Begin int64 // Unix time.
|
||||
End int64 // Unix time.
|
||||
BeginID string
|
||||
EndID string
|
||||
Keyword string
|
||||
To string
|
||||
From string
|
||||
Subject string
|
||||
ConversationID string
|
||||
AddressID string
|
||||
ID []string
|
||||
Attachments *bool
|
||||
Unread *bool
|
||||
ExternalID string // MIME Message-Id (only valid for messages).
|
||||
AutoWildcard *bool
|
||||
}
|
||||
|
||||
func (filter *MessagesFilter) urlValues() url.Values { // nolint[funlen]
|
||||
v := url.Values{}
|
||||
|
||||
if filter.Page != 0 {
|
||||
v.Set("Page", strconv.Itoa(filter.Page))
|
||||
}
|
||||
if filter.PageSize != 0 {
|
||||
v.Set("PageSize", strconv.Itoa(filter.PageSize))
|
||||
}
|
||||
if filter.Limit != 0 {
|
||||
v.Set("Limit", strconv.Itoa(filter.Limit))
|
||||
}
|
||||
if filter.LabelID != "" {
|
||||
v.Set("LabelID", filter.LabelID)
|
||||
}
|
||||
if filter.Sort != "" {
|
||||
v.Set("Sort", filter.Sort)
|
||||
}
|
||||
if filter.Desc != nil {
|
||||
if *filter.Desc {
|
||||
v.Set("Desc", "1")
|
||||
} else {
|
||||
v.Set("Desc", "0")
|
||||
}
|
||||
}
|
||||
if filter.Begin != 0 {
|
||||
v.Set("Begin", strconv.Itoa(int(filter.Begin)))
|
||||
}
|
||||
if filter.End != 0 {
|
||||
v.Set("End", strconv.Itoa(int(filter.End)))
|
||||
}
|
||||
if filter.BeginID != "" {
|
||||
v.Set("BeginID", filter.BeginID)
|
||||
}
|
||||
if filter.EndID != "" {
|
||||
v.Set("EndID", filter.EndID)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
v.Set("Keyword", filter.Keyword)
|
||||
}
|
||||
if filter.To != "" {
|
||||
v.Set("To", filter.To)
|
||||
}
|
||||
if filter.From != "" {
|
||||
v.Set("From", filter.From)
|
||||
}
|
||||
if filter.Subject != "" {
|
||||
v.Set("Subject", filter.Subject)
|
||||
}
|
||||
if filter.ConversationID != "" {
|
||||
v.Set("ConversationID", filter.ConversationID)
|
||||
}
|
||||
if filter.AddressID != "" {
|
||||
v.Set("AddressID", filter.AddressID)
|
||||
}
|
||||
if len(filter.ID) > 0 {
|
||||
for _, id := range filter.ID {
|
||||
v.Add("ID[]", id)
|
||||
}
|
||||
}
|
||||
if filter.Attachments != nil {
|
||||
if *filter.Attachments {
|
||||
v.Set("Attachments", "1")
|
||||
} else {
|
||||
v.Set("Attachments", "0")
|
||||
}
|
||||
}
|
||||
if filter.Unread != nil {
|
||||
if *filter.Unread {
|
||||
v.Set("Unread", "1")
|
||||
} else {
|
||||
v.Set("Unread", "0")
|
||||
}
|
||||
}
|
||||
if filter.ExternalID != "" {
|
||||
v.Set("ExternalID", filter.ExternalID)
|
||||
}
|
||||
if filter.AutoWildcard != nil {
|
||||
if *filter.AutoWildcard {
|
||||
v.Set("AutoWildcard", "1")
|
||||
} else {
|
||||
v.Set("AutoWildcard", "0")
|
||||
}
|
||||
}
|
||||
|
||||
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 := NewRequest("GET", "/messages", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
msgs, total, err = res.Messages, res.Total, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type MessagesCountsRes struct {
|
||||
Res
|
||||
|
||||
Counts []*MessagesCount
|
||||
}
|
||||
|
||||
// CountMessages counts messages by label.
|
||||
func (c *Client) CountMessages(addressID string) (counts []*MessagesCount, err error) {
|
||||
reqURL := "/messages/count"
|
||||
if addressID != "" {
|
||||
reqURL += ("?AddressID=" + addressID)
|
||||
}
|
||||
req, err := 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
|
||||
}
|
||||
|
||||
// GetMessage retrieves a message.
|
||||
func (c *Client) GetMessage(id string) (msg *Message, err error) {
|
||||
req, err := NewRequest("GET", "/messages/"+id, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessageRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg, err = res.Message, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type SendMessageReq struct {
|
||||
ExpirationTime int64 `json:",omitempty"`
|
||||
// AutoSaveContacts int `json:",omitempty"`
|
||||
|
||||
// Data for encrypted recipients.
|
||||
Packages []*MessagePackage
|
||||
}
|
||||
|
||||
// Message package types.
|
||||
const (
|
||||
InternalPackage = 1
|
||||
EncryptedOutsidePackage = 2
|
||||
ClearPackage = 4
|
||||
PGPInlinePackage = 8
|
||||
PGPMIMEPackage = 16
|
||||
ClearMIMEPackage = 32
|
||||
)
|
||||
|
||||
// Signature types.
|
||||
const (
|
||||
NoSignature = 0
|
||||
YesSignature = 1
|
||||
)
|
||||
|
||||
type MessagePackage struct {
|
||||
Addresses map[string]*MessageAddress
|
||||
Type int
|
||||
MIMEType string
|
||||
Body string // base64-encoded encrypted data packet.
|
||||
BodyKey AlgoKey // base64-encoded session key (only if cleartext recipients).
|
||||
AttachmentKeys map[string]AlgoKey // Only include if cleartext & attachments.
|
||||
}
|
||||
|
||||
type MessageAddress struct {
|
||||
Type int
|
||||
BodyKeyPacket string // base64-encoded key packet.
|
||||
Signature int // 0 = None, 1 = Detached, 2 = Attached/Armored
|
||||
AttachmentKeyPackets map[string]string
|
||||
}
|
||||
|
||||
type AlgoKey struct {
|
||||
Key string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
type SendMessageRes struct {
|
||||
Res
|
||||
|
||||
Sent *Message
|
||||
|
||||
// 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 := NewJSONRequest("POST", "/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
|
||||
}
|
||||
|
||||
const (
|
||||
DraftActionReply = 0
|
||||
DraftActionReplyAll = 1
|
||||
DraftActionForward = 2
|
||||
)
|
||||
|
||||
type DraftReq struct {
|
||||
Message *Message
|
||||
ParentID string `json:",omitempty"`
|
||||
Action int
|
||||
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 := NewJSONRequest("POST", "/messages", createReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessageRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
created, err = res.Message, res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
type MessagesActionReq struct {
|
||||
IDs []string
|
||||
}
|
||||
|
||||
type MessagesActionRes struct {
|
||||
Res
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return c.doMessagesActionInner(action, ids)
|
||||
}
|
||||
|
||||
// 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 := NewJSONRequest("PUT", "/messages/"+action, actionReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessagesActionRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) MarkMessagesRead(ids []string) error {
|
||||
return c.doMessagesAction("read", ids)
|
||||
}
|
||||
|
||||
func (c *Client) MarkMessagesUnread(ids []string) error {
|
||||
return c.doMessagesAction("unread", ids)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteMessages(ids []string) error {
|
||||
return c.doMessagesAction("delete", ids)
|
||||
}
|
||||
|
||||
func (c *Client) UndeleteMessages(ids []string) error {
|
||||
return c.doMessagesAction("undelete", ids)
|
||||
}
|
||||
|
||||
type LabelMessagesReq struct {
|
||||
LabelID string
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return c.labelMessages(ids, label)
|
||||
}
|
||||
|
||||
func (c *Client) labelMessages(ids []string, label string) (err error) {
|
||||
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
|
||||
req, err := NewJSONRequest("PUT", "/messages/label", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res MessagesActionRes
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return c.unlabelMessages(ids, label)
|
||||
}
|
||||
|
||||
func (c *Client) unlabelMessages(ids []string, label string) (err error) {
|
||||
labelReq := &LabelMessagesReq{LabelID: label, IDs: ids}
|
||||
req, err := NewJSONRequest("PUT", "/messages/unlabel", labelReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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 := "/messages/empty?LabelID=" + labelID
|
||||
if addressID != "" {
|
||||
reqURL += ("&AddressID=" + addressID)
|
||||
}
|
||||
|
||||
req, err := NewRequest("DELETE", reqURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res Res
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = res.Err()
|
||||
return
|
||||
}
|
||||
223
pkg/pmapi/messages_test.go
Normal file
223
pkg/pmapi/messages_test.go
Normal file
@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"testing"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testMessageCleartext = `<div>jeej saas<br></div><div><br></div><div class="protonmail_signature_block"><div>Sent from <a href="https://protonmail.ch">ProtonMail</a>, encrypted email based in Switzerland.<br></div><div><br></div></div>`
|
||||
const testMessageCleartextLegacy = `<div>flkasjfkjasdklfjasd<br></div><div>fasd<br></div><div>jfasjdfjasd<br></div><div>fj<br></div><div>asdfj<br></div><div>sadjf<br></div><div>sadjf<br></div><div>asjdf<br></div><div>jasd<br></div><div>fj<br></div><div>asdjf<br></div><div>asdjfsad<br></div><div>fasdlkfjasdjfkljsadfljsdfjsdljflkdsjfkljsdlkfjsdlk<br></div><div>jasfd<br></div><div>jsd<br></div><div>jf<br></div><div>sdjfjsdf<br></div><div><br></div><div>djfskjsladf<br></div><div>asd<br></div><div>fja<br></div><div>sdjfajsf<br></div><div>jas<br></div><div>fas<br></div><div>fj<br></div><div>afj<br></div><div>ajf<br></div><div>af<br></div><div>asdfasdfasd<br></div><div>Sent from <a href="https://protonmail.ch">ProtonMail</a>, encrypted email based in Switzerland.<br></div><div>dshfljsadfasdf<br></div><div>as<br></div><div>df<br></div><div>asd<br></div><div>fasd<br></div><div>f<br></div><div>asd<br></div><div>fasdflasdklfjsadlkjf</div><div>asd<br></div><div>fasdlkfjasdlkfjklasdjflkasjdflaslkfasdfjlasjflkasflksdjflkjasdf<br></div><div>asdflkasdjflajsfljaslkflasf<br></div><div>asdfkas<br></div><div>dfjas<br></div><div>djf<br></div><div>asjf<br></div><div>asj<br></div><div>faj<br></div><div>f<br></div><div>afj<br></div><div>sdjaf<br></div><div>jas<br></div><div>sdfj<br></div><div>ajf<br></div><div>aj<br></div><div>ajsdafafdaaf<br></div><div>a<br></div><div>f<br></div><div>lasl;ga<br></div><div>sags<br></div><div>ad<br></div><div>gags<br></div><div>g<br></div><div>ga<br></div><div>a<br></div><div>gg<br></div><div>a<br></div><div>ag<br></div><div>ag<br></div><div>agga.g.ga,ag.ag./ga<br></div><div><br></div><div>dsga<br></div><div>sg<br></div><div><br></div><div>gasga\g\g\g\g\g\n\y\t\r\\r\r\\n\n\n\<br></div><div><br></div><div><br></div><div>sd<br></div><div>asdf<br></div><div>asdf<br></div><div>dsa<br></div><div>fasd<br></div><div>f</div>`
|
||||
|
||||
const testMessageEncrypted = `-----BEGIN PGP MESSAGE-----
|
||||
Version: OpenPGP.js v1.2.0
|
||||
Comment: http://openpgpjs.org
|
||||
|
||||
wcBMA0fcZ7XLgmf2AQf+JPulpEOWwmY/Sfze8rBpYvrO2cebSSkjCgapFfXG
|
||||
CI4PA+rb+WGkn9uBJf3FgEEg76c2ZqGh9zXTyrdHyFLm8ekarvxzgLpvcei/
|
||||
p18IzcxsWnaM+1uknL4bKUtK3298gIl6xrfc4eVEA8tqUPUkSLSGk7uggjhj
|
||||
zEYR4zIgMa0c6sMVcZ1Idvy9gGsTIvvcZJ4h1lKVUl8gba+qr1D76RaAf5xS
|
||||
SBT74q9HhgfEMZwk6hXAp4MYY5h+lIsuhFu5kQ9fhZKU0PWS7ljddv854ZxS
|
||||
9gHKPBerv4NBjkkCLp9xa2QNjDnu1fNlzlJpfCavp6wDdC83GiT61VRHPE4s
|
||||
J9LASwFwgOrPmB8Mi867AQM0dddbj4Qe5ghlUcF1XnybkwfHqvQA1QT50d5n
|
||||
ddFyxwIjvI/Nsn8MTCSnmrWCrjQ7v8JC73NyGxO5k6ZlUnc6BQVie78QJo5a
|
||||
ftzl5b6nwlCYuXI8R6N/t5MXzrC5GwR8nvjH6kgbUVTLL1hO2Sbgyq5bBKLW
|
||||
jjylTsZDHUGi4OX7q7eet5/RhKusWdvR0cHEaZAVD6BhTNN0mFBJ5bM1SINI
|
||||
9gxJVqKJe7j4nJP4PGZBJrokZihhiBS/WEbJdvS54frYajGKjMavB3VhFP6k
|
||||
qi5aiqGJKOJOV/G8yIwtdtxac3UL34eWo69U39Zx2mNfSXCzSjuafCr1nmAS
|
||||
4g==
|
||||
=Uw3B
|
||||
-----END PGP MESSAGE-----
|
||||
`
|
||||
|
||||
const testMessageEncryptedLegacy = `---BEGIN ENCRYPTED MESSAGE---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---END ENCRYPTED MESSAGE---||---BEGIN ENCRYPTED RANDOM KEY--------BEGIN PGP MESSAGE-----
|
||||
Version: OpenPGP.js v0.9.0
|
||||
Comment: http://openpgpjs.org
|
||||
|
||||
wcBMA2tjJVxNCRhtAQf/YzkQoUqaqa3NJ/c1apIF/dsl7yJ4GdVrC3/w7lxE
|
||||
2CO5ioQD4s6QMWP2Y9dOdVl2INwz8eXOds9NS+1nMs4SoMbrpJnAjx8Cthti
|
||||
1Z/8eWMU023LYahds8BYM0T435K/2tTB5GTA4uTl2y8Xzz2PbptQ4PrUDaII
|
||||
+egeQQyPA0yuoRDwpaeTiaBYOSa06YYuK5Agr0buQAxRIMCxI2o+fucjoabv
|
||||
FsQHKGu20U5GlJroSIyIVVkaH3evhNti/AnYX1HuokcGEQNsF5vo4SjWcH23
|
||||
2P86EIV+w5lUWC1FN9vZCyvbvyuqLHQMtqKVn4GBOkIc3bYQ0jru3a0FG4Cx
|
||||
bNJ0ASps2+p3Vxe0d+so2iFV92ByQ+0skyCUwCNUlwOV5V5f2fy1ImXk4mXI
|
||||
cO/bcbqRxx3pG9gkPIh43FoQktTT+tsJ5vS53qfaLGdhCYfkrWjsKu+2P9Xg
|
||||
+Cr8clh6NTblhfkoAS1gzjA3XgsgEFrtP+OGqwg=
|
||||
=c5WU
|
||||
-----END PGP MESSAGE-----
|
||||
---END ENCRYPTED RANDOM KEY---
|
||||
`
|
||||
|
||||
const testMessageSigned = `-----BEGIN PGP MESSAGE-----
|
||||
Version: OpenPGP.js v4.5.3
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wcBMA0fcZ7XLgmf2AQgAgnHOlcAwVu2AnVfi2fIQHSkTQ0OFnZMJMRR3MJ1q
|
||||
HtUW8jkSLcurL0Sn/tBFLIIR4YT2tQMzV7cvZzZyBEuZM4OYnDp8xSmoszPh
|
||||
Gc/nvYG0A0pmKAQkL27v05Dul8oUWA0APT51urghH2Pzm7NdOMtTKIE4LQjS
|
||||
mBfQ6Cf14uKV0xGS9v2dSFjFxxXEEpMQ+k60NCKRYClN2LVVxf3OKXbuugds
|
||||
m2GUGn3CuFsiabosIUv4EcdE3aD9HbNo+PIWLJWRJIYJSc5+FWcbwXuIIFgC
|
||||
XX1s7OV53ceZJnhjCmDE0N2ZOLLAYWED2zRvUa+CAqG+hZgc/3Ia+UmJUVuZ
|
||||
BNLAugFuRsOVgh3olUIz0vazHhyGG0XIsNqmRm0U9SIfhWkPPHBmU6Xht6Qw
|
||||
EvLbBfKTYHxX01yQUNgIv4S/TULeQuUjZQfsNYNXXGepS+jiCoIdEgUwpvre
|
||||
OMFGsypwQXVCFYO/GQdYanMQRTckEexyBY4hGYVrevDM1yG/zGJIdbfI2L+1
|
||||
1cz76jI8PtzL+S0zcVkevLcjjsHm2Je959uSida9jara7Bymr0y56UdoXoWX
|
||||
4vZ0kQNo58eEEV0zg7dit4lDvwcuSZMW6K//xNtRQ4QX7/EDtlcYqBJXPwJY
|
||||
eQSBVeYbeUbZ+PHJdu5gbI85BJNE2dKcS1bdOhEU2lPLYpvmMpPdot9TwnJb
|
||||
dN3l8yDyhScGvTIZqlxhU7HCM9VHAS0bDqCUoO8EruztUSgjMI+gKC9+xdVU
|
||||
yrkF7K23UNLWflROMv4cp0LDRB57619Y2w5lY/MG5bS0jSfMWBwnJG2AF28c
|
||||
2tYKnHw6rpZXvXnlDmEDT8suTzuTGA==
|
||||
=Sir8
|
||||
-----END PGP MESSAGE-----
|
||||
`
|
||||
|
||||
const testMessageSigner = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: OpenPGP.js v0.7.1
|
||||
Comment: http://openpgpjs.org
|
||||
|
||||
xsBNBFSI0BMBB/9td6B5RDzVSFTlFzYOS4JxIb5agtNW1rbA4FeLoC47bGLR
|
||||
8E42IA6aKcO4H0vOZ1lFms0URiKk1DjCMXn3AUErbxqiV5IATRZLwliH6vwy
|
||||
PI6j5rtGF8dyxYfwmLtoUNkDcPdcFEb4NCdowsN7e8tKU0bcpouZcQhAqawC
|
||||
9nEdaG/gS5w+2k4hZX2lOKS1EF5SvP48UadlspEK2PLAIp5wB9XsFS9ey2wu
|
||||
elzkSfDh7KUAlteqFGSMqIgYH62/gaKm+TcckfZeyiMHWFw6sfrcFQ3QOZPq
|
||||
ahWt0Rn9XM5xBAxx5vW0oceuQ1vpvdfFlM5ix4gn/9w6MhmStaCee8/fABEB
|
||||
AAHNBlVzZXJJRMLAcgQQAQgAJgUCVIjQHQYLCQgHAwIJEASDR1Fk7GNTBBUI
|
||||
AgoDFgIBAhsDAh4BAADmhAf/Yt0mCfWqQ25NNGUN14pKKgnPm68zwj1SmMGa
|
||||
pU7+7ItRpoFNaDwV5QYiQSLC1SvSb1ZeKoY928GPKfqYyJlBpTPL9zC1OHQj
|
||||
9+2yYauHjYW9JWQM7hst2S2LBcdiQPOs3ybWPaO9yaccV4thxKOCPvyClaS5
|
||||
b9T4Iv9GEVZQIUvArkwI8hyzIi6skRgxflGheq1O+S1W4Gzt2VtYvo8g8r6W
|
||||
GzAGMw2nrs2h0+vUr+dLDgIbFCTc5QU99d5jE/e5Hw8iqBxv9tqB1hVATf8T
|
||||
wC8aU5MTtxtabOiBgG0PsBs6oIwjFqEjpOIza2/AflPZfo7stp6IiwbwvTHo
|
||||
1NlHoM7ATQRUiNAdAQf/eOLJYxX4lUQUzrNQgASDNE8gJPj7ywcGzySyqr0Y
|
||||
5rbG57EjtKMIgZrpzJRpSCuRbBjfsltqJ5Q9TBAbPO+oR3rue0LqPKMnmr/q
|
||||
KsHswBJRfsb/dbktUNmv/f7R9IVyOuvyP6RgdGeloxdGNeWiZSA6AZYI+WGc
|
||||
xaOvVDPz8thtnML4G4MUhXxxNZ7JzQ0Lfz6mN8CCkblIP5xpcJsyRU7lUsGD
|
||||
EJGZX0JH/I8bRVN1Xu08uFinIkZyiXRJ5ZGgF3Dns6VbIWmbttY54tBELtk+
|
||||
5g9pNSl9qiYwiCdwuZrA//NmD3xlZIN8sG4eM7ZUibZ23vEq+bUt1++6Mpba
|
||||
GQARAQABwsBfBBgBCAATBQJUiNAfCRAEg0dRZOxjUwIbDAAAlpMH/085qZdO
|
||||
mGRAlbvViUNhF2rtHvCletC48WHGO1ueSh9VTxalkP21YAYLJ4JgJzArJ7tH
|
||||
lEeiKiHm8YU9KhLe11Yv/o3AiKIAQjJiQluvk+mWdMcddB4fBjL6ttMTRAXe
|
||||
gHnjtMoamHbSZdeUTUadv05Fl6ivWtpXlODG4V02YvDiGBUbDosdGXEqDtpT
|
||||
g6MYlj3QMvUiUNQvt7YGMJS8A9iQ9qBNzErgRW8L6CON2RmpQ/wgwP5nwUHz
|
||||
JjY51d82Vj8bZeI8LdsX41SPoUhyC7kmNYpw9ZRy7NlrCt8dBIOB4/BKEJ2G
|
||||
ClW54lp9eeOfYTsdTSbn9VaSO0E6m2/Q4Tk=
|
||||
=WFtr
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
func TestMessage_IsBodyEncrypted(t *testing.T) {
|
||||
msg := &Message{Body: testMessageEncrypted}
|
||||
Assert(t, msg.IsBodyEncrypted(), "the body should be encrypted")
|
||||
|
||||
msg.Body = testMessageCleartext
|
||||
Assert(t, !msg.IsBodyEncrypted(), "the body should not be encrypted")
|
||||
}
|
||||
|
||||
func TestMessage_Decrypt(t *testing.T) {
|
||||
msg := &Message{Body: testMessageEncrypted}
|
||||
err := msg.Decrypt(testPrivateKeyRing)
|
||||
Ok(t, err)
|
||||
Equals(t, testMessageCleartext, msg.Body)
|
||||
}
|
||||
|
||||
func TestMessage_Decrypt_Legacy(t *testing.T) {
|
||||
testPrivateKeyLegacy := readTestFile("testPrivateKeyLegacy", false)
|
||||
testPrivateKeyRingLegacy, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKeyLegacy))
|
||||
Ok(t, err)
|
||||
|
||||
Ok(t, testPrivateKeyRingLegacy.Unlock([]byte(testMailboxPasswordLegacy)))
|
||||
|
||||
msg := &Message{Body: testMessageEncryptedLegacy}
|
||||
|
||||
err = msg.Decrypt(testPrivateKeyRingLegacy)
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, testMessageCleartextLegacy, msg.Body)
|
||||
}
|
||||
|
||||
func TestMessage_Decrypt_signed(t *testing.T) {
|
||||
msg := &Message{Body: testMessageSigned}
|
||||
err := msg.Decrypt(testPrivateKeyRing)
|
||||
Ok(t, err)
|
||||
Equals(t, testMessageCleartext, msg.Body)
|
||||
}
|
||||
|
||||
func TestMessage_Encrypt(t *testing.T) {
|
||||
signer, err := pmcrypto.ReadArmoredKeyRing(strings.NewReader(testMessageSigner))
|
||||
Ok(t, err)
|
||||
|
||||
msg := &Message{Body: testMessageCleartext}
|
||||
Ok(t, msg.Encrypt(testPrivateKeyRing, testPrivateKeyRing))
|
||||
|
||||
err = msg.Decrypt(testPrivateKeyRing)
|
||||
Ok(t, err)
|
||||
|
||||
Equals(t, testMessageCleartext, msg.Body)
|
||||
Equals(t, testIdentity, signer.Identities()[0])
|
||||
}
|
||||
|
||||
func routeLabelMessages(tb testing.TB, w http.ResponseWriter, r *http.Request) string {
|
||||
Ok(tb, checkMethodAndPath(r, "PUT", "/messages/label"))
|
||||
|
||||
return "messages/label/put_response.json"
|
||||
}
|
||||
|
||||
func TestMessage_LabelMessages_NoPaging(t *testing.T) {
|
||||
// This should be only enough IDs to produce one page.
|
||||
testIDs := []string{}
|
||||
for i := 0; i < messageIDPageSize-1; i++ {
|
||||
testIDs = append(testIDs, fmt.Sprintf("%v", i))
|
||||
}
|
||||
|
||||
// There should be enough IDs to produce just one page so the endpoint should be called once.
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeLabelMessages,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
|
||||
}
|
||||
|
||||
func TestMessage_LabelMessages_Paging(t *testing.T) {
|
||||
// This should be enough IDs to produce three pages.
|
||||
testIDs := []string{}
|
||||
for i := 0; i < 3*messageIDPageSize; i++ {
|
||||
testIDs = append(testIDs, fmt.Sprintf("%v", i))
|
||||
}
|
||||
|
||||
// There should be enough IDs to produce three pages so the endpoint should be called three times.
|
||||
finish, c := newTestServerCallbacks(t,
|
||||
routeLabelMessages,
|
||||
routeLabelMessages,
|
||||
routeLabelMessages,
|
||||
)
|
||||
defer finish()
|
||||
|
||||
c.uid = testUID
|
||||
c.accessToken = testAccessToken
|
||||
|
||||
assert.NoError(t, c.LabelMessages(testIDs, "mylabel"))
|
||||
}
|
||||
43
pkg/pmapi/metrics.go
Normal file
43
pkg/pmapi/metrics.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2020 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 := 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
|
||||
}
|
||||
43
pkg/pmapi/metrics_test.go
Normal file
43
pkg/pmapi/metrics_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testSendSimpleMetricsBody = `{
|
||||
"Code": 1000
|
||||
}
|
||||
`
|
||||
|
||||
func TestClient_SendSimpleMetric(t *testing.T) {
|
||||
s, c := newTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Ok(t, checkMethodAndPath(r, "GET", "/metrics?Action=some_action&Category=some_category&Label=some_label"))
|
||||
|
||||
fmt.Fprint(w, testSendSimpleMetricsBody)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
err := c.SendSimpleMetric("some_category", "some_action", "some_label")
|
||||
if err != nil {
|
||||
t.Fatal("Expected no error while sending simple metric, got:", err)
|
||||
}
|
||||
}
|
||||
47
pkg/pmapi/passwords.go
Normal file
47
pkg/pmapi/passwords.go
Normal file
@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
|
||||
"github.com/jameskeane/bcrypt"
|
||||
)
|
||||
|
||||
func HashMailboxPassword(password, keySalt string) (hashedPassword string, err error) {
|
||||
if keySalt == "" {
|
||||
hashedPassword = password
|
||||
return
|
||||
}
|
||||
decodedSalt, err := base64.StdEncoding.DecodeString(keySalt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
encodedSalt := base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding).EncodeToString(decodedSalt)
|
||||
hashResult, err := bcrypt.Hash(password, "$2y$10$"+encodedSalt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(hashResult) != 60 {
|
||||
err = errors.New("pmapi: invalid mailbox password hash")
|
||||
return
|
||||
}
|
||||
hashedPassword = hashResult[len(hashResult)-31:]
|
||||
return
|
||||
}
|
||||
62
pkg/pmapi/pmapi_test.go
Normal file
62
pkg/pmapi/pmapi_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2020 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 (
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
)
|
||||
|
||||
const testMailboxPassword = "apple"
|
||||
const testMailboxPasswordLegacy = "123"
|
||||
|
||||
var (
|
||||
testPrivateKeyRing *pmcrypto.KeyRing
|
||||
testPublicKeyRing *pmcrypto.KeyRing
|
||||
)
|
||||
|
||||
func init() {
|
||||
testPrivateKey := readTestFile("testPrivateKey", false)
|
||||
testPublicKey := readTestFile("testPublicKey", false)
|
||||
|
||||
var err error
|
||||
if testPrivateKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPrivateKey)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if testPublicKeyRing, err = pmcrypto.ReadArmoredKeyRing(strings.NewReader(testPublicKey)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := testPrivateKeyRing.Unlock([]byte(testMailboxPassword)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(name string, trimNewlines bool) string { // nolint[unparam]
|
||||
data, err := ioutil.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if trimNewlines {
|
||||
return strings.TrimRight(string(data), "\n")
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
304
pkg/pmapi/proxy.go
Normal file
304
pkg/pmapi/proxy.go
Normal file
@ -0,0 +1,304 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyRevertTime = 24 * time.Hour
|
||||
proxySearchTimeout = 30 * time.Second
|
||||
proxyQueryTimeout = 10 * time.Second
|
||||
proxyLookupWait = 5 * time.Second
|
||||
proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
)
|
||||
|
||||
var dohProviders = []string{ //nolint[gochecknoglobals]
|
||||
"https://dns11.quad9.net/dns-query",
|
||||
"https://dns.google/dns-query",
|
||||
}
|
||||
|
||||
// globalAllowDoH controls whether or not to enable use of DoH/Proxy in pmapi.
|
||||
var globalAllowDoH = false // nolint[golint]
|
||||
|
||||
// globalProxyMutex allows threadsafe modification of proxy state.
|
||||
var globalProxyMutex = sync.RWMutex{} // nolint[golint]
|
||||
|
||||
// globalOriginalURL backs up the original API url so it can be restored later.
|
||||
var globalOriginalURL = RootURL // nolint[golint]
|
||||
|
||||
// globalIsDoHAllowed returns whether or not to use DoH.
|
||||
func globalIsDoHAllowed() bool { // nolint[golint]
|
||||
globalProxyMutex.RLock()
|
||||
defer globalProxyMutex.RUnlock()
|
||||
|
||||
return globalAllowDoH
|
||||
}
|
||||
|
||||
// GlobalAllowDoH enables DoH.
|
||||
func GlobalAllowDoH() { // nolint[golint]
|
||||
globalProxyMutex.Lock()
|
||||
defer globalProxyMutex.Unlock()
|
||||
|
||||
globalAllowDoH = true
|
||||
}
|
||||
|
||||
// GlobalDisallowDoH disables DoH and sets the RootURL back to what it was.
|
||||
func GlobalDisallowDoH() { // nolint[golint]
|
||||
globalProxyMutex.Lock()
|
||||
defer globalProxyMutex.Unlock()
|
||||
|
||||
globalAllowDoH = false
|
||||
RootURL = globalOriginalURL
|
||||
}
|
||||
|
||||
// globalSetRootURL sets the global RootURL.
|
||||
func globalSetRootURL(url string) { // nolint[golint]
|
||||
globalProxyMutex.Lock()
|
||||
defer globalProxyMutex.Unlock()
|
||||
|
||||
RootURL = url
|
||||
}
|
||||
|
||||
// GlobalGetRootURL returns the global RootURL.
|
||||
func GlobalGetRootURL() (url string) { // nolint[golint]
|
||||
globalProxyMutex.RLock()
|
||||
defer globalProxyMutex.RUnlock()
|
||||
|
||||
return RootURL
|
||||
}
|
||||
|
||||
// isProxyEnabled returns whether or not we are currently using a proxy.
|
||||
func isProxyEnabled() bool { // nolint[golint]
|
||||
return globalOriginalURL != GlobalGetRootURL()
|
||||
}
|
||||
|
||||
// proxyManager manages known proxies.
|
||||
type proxyManager struct {
|
||||
// dohLookup is used to look up the given query at the given DoH provider, returning the TXT records>
|
||||
dohLookup func(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.
|
||||
|
||||
useDuration time.Duration // How much time to use the proxy before returning to the original API.
|
||||
findTimeout, lookupTimeout time.Duration // Timeouts for DNS query and proxy search.
|
||||
|
||||
lastLookup time.Time // The time at which we last attempted to find a proxy.
|
||||
}
|
||||
|
||||
// newProxyManager creates a new proxyManager that queries the given DoH providers
|
||||
// to retrieve DNS records for the given query string.
|
||||
func newProxyManager(providers []string, query string) (p *proxyManager) { // nolint[unparam]
|
||||
p = &proxyManager{
|
||||
providers: providers,
|
||||
query: query,
|
||||
useDuration: proxyRevertTime,
|
||||
findTimeout: proxySearchTimeout,
|
||||
lookupTimeout: proxyQueryTimeout,
|
||||
}
|
||||
|
||||
// Use the default DNS lookup method; this can be overridden if necessary.
|
||||
p.dohLookup = p.defaultDoHLookup
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// findProxy returns a new proxy domain which is not equal to the current RootURL.
|
||||
// It returns an error if the process takes longer than ProxySearchTime.
|
||||
func (p *proxyManager) findProxy() (proxy string, err error) {
|
||||
if time.Now().Before(p.lastLookup.Add(proxyLookupWait)) {
|
||||
return "", errors.New("not looking for a proxy, too soon")
|
||||
}
|
||||
|
||||
p.lastLookup = time.Now()
|
||||
|
||||
proxyResult := make(chan string)
|
||||
errResult := make(chan error)
|
||||
go func() {
|
||||
if err = p.refreshProxyCache(); err != nil {
|
||||
logrus.WithError(err).Warn("Failed to refresh proxy cache, cache may be out of date")
|
||||
}
|
||||
|
||||
for _, proxy := range p.proxyCache {
|
||||
if proxy != stripProtocol(GlobalGetRootURL()) && p.canReach(proxy) {
|
||||
proxyResult <- proxy
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
errResult <- errors.New("no proxy available")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(p.findTimeout):
|
||||
logrus.Error("Timed out finding a proxy server")
|
||||
return "", errors.New("timed out finding a proxy")
|
||||
|
||||
case proxy = <-proxyResult:
|
||||
logrus.WithField("proxy", proxy).Info("Found proxy server")
|
||||
return
|
||||
|
||||
case err = <-errResult:
|
||||
logrus.WithError(err).Error("Failed to find available proxy server")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// useProxy sets the proxy server to use. It returns to the original RootURL after 24 hours.
|
||||
func (p *proxyManager) useProxy(proxy string) {
|
||||
if !isProxyEnabled() {
|
||||
p.disableProxyAfter(p.useDuration)
|
||||
}
|
||||
|
||||
globalSetRootURL(https(proxy))
|
||||
}
|
||||
|
||||
// disableProxyAfter disables the proxy after the given amount of time.
|
||||
func (p *proxyManager) disableProxyAfter(d time.Duration) {
|
||||
go func() {
|
||||
<-time.After(d)
|
||||
globalSetRootURL(globalOriginalURL)
|
||||
}()
|
||||
}
|
||||
|
||||
// refreshProxyCache loads the latest proxies from the known providers.
|
||||
func (p *proxyManager) refreshProxyCache() error {
|
||||
logrus.Info("Refreshing proxy cache")
|
||||
|
||||
for _, provider := range p.providers {
|
||||
if proxies, err := p.dohLookup(p.query, provider); err == nil {
|
||||
p.proxyCache = proxies
|
||||
|
||||
// We also want to allow bridge to switch back to the standard API at any time.
|
||||
p.proxyCache = append(p.proxyCache, globalOriginalURL)
|
||||
|
||||
logrus.WithField("proxies", proxies).Info("Available proxies")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("lookup failed with all DoH providers")
|
||||
}
|
||||
|
||||
// canReach returns whether we can reach the given url.
|
||||
// NOTE: we skip cert verification to stop it complaining that cert name doesn't match hostname.
|
||||
func (p *proxyManager) canReach(url string) bool {
|
||||
pinger := resty.New().
|
||||
SetHostURL(https(url)).
|
||||
SetTimeout(p.lookupTimeout).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) // nolint[gosec]
|
||||
|
||||
if _, err := pinger.R().Get("/tests/ping"); err != nil {
|
||||
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 ProxyQueryTime then an error is returned.
|
||||
func (p *proxyManager) defaultDoHLookup(query, dohProvider string) (data []string, err error) {
|
||||
dataResult := make(chan []string)
|
||||
errResult := 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 {
|
||||
errResult <- 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().SetQueryParam("dns", encodedRequest).Get(dohProvider)
|
||||
if err != nil {
|
||||
errResult <- 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 {
|
||||
errResult <- 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...)
|
||||
}
|
||||
}
|
||||
|
||||
dataResult <- data
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(p.lookupTimeout):
|
||||
logrus.WithField("provider", dohProvider).Error("Timed out querying DNS records")
|
||||
return []string{}, errors.New("timed out querying DNS records")
|
||||
|
||||
case data = <-dataResult:
|
||||
logrus.WithField("data", data).Info("Received TXT records")
|
||||
return
|
||||
|
||||
case err = <-errResult:
|
||||
logrus.WithField("provider", dohProvider).WithError(err).Error("Failed to query DNS records")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func stripProtocol(url string) string {
|
||||
if strings.HasPrefix(url, "https://") {
|
||||
return strings.TrimPrefix(url, "https://")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(url, "http://") {
|
||||
return strings.TrimPrefix(url, "http://")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
func https(url string) string {
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
304
pkg/pmapi/proxy_test.go
Normal file
304
pkg/pmapi/proxy_test.go
Normal file
@ -0,0 +1,304 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
TestQuad9Provider = "https://dns11.quad9.net/dns-query"
|
||||
TestGoogleProvider = "https://dns.google/dns-query"
|
||||
)
|
||||
|
||||
func TestProxyManager_FindProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy.URL, url)
|
||||
}
|
||||
|
||||
func TestProxyManager_FindProxy_ChooseReachableProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
goodProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
// Close the bad proxy first so it isn't reachable; we should then choose the good proxy.
|
||||
badProxy.Close()
|
||||
defer goodProxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, goodProxy.URL}, nil }
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, goodProxy.URL, url)
|
||||
}
|
||||
|
||||
func TestProxyManager_FindProxy_FailIfNoneReachable(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
badProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
anotherBadProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
// Close the proxies to simulate them not being reachable.
|
||||
badProxy.Close()
|
||||
anotherBadProxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{badProxy.URL, anotherBadProxy.URL}, nil }
|
||||
|
||||
_, err := p.findProxy()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyManager_FindProxy_LookupTimeout(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.lookupTimeout = time.Second
|
||||
p.dohLookup = func(q, p string) ([]string, error) { time.Sleep(2 * time.Second); return nil, nil }
|
||||
|
||||
// The findProxy should fail because lookup takes 2 seconds but we only allow 1 second.
|
||||
_, err := p.findProxy()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyManager_FindProxy_FindTimeout(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
slowProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
}))
|
||||
defer slowProxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.findTimeout = time.Second
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{slowProxy.URL}, nil }
|
||||
|
||||
// The findProxy should fail because lookup takes 2 seconds but we only allow 1 second.
|
||||
_, err := p.findProxy()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyManager_UseProxy(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy.URL, GlobalGetRootURL())
|
||||
}
|
||||
|
||||
func TestProxyManager_UseProxy_MultipleTimes(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy1.Close()
|
||||
proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy2.Close()
|
||||
proxy3 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy3.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL}, nil }
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy1.URL, GlobalGetRootURL())
|
||||
|
||||
// Have to wait so as to not get rejected.
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy2.URL}, nil }
|
||||
url, err = p.findProxy()
|
||||
require.NoError(t, err)
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy2.URL, GlobalGetRootURL())
|
||||
|
||||
// Have to wait so as to not get rejected.
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy3.URL}, nil }
|
||||
url, err = p.findProxy()
|
||||
require.NoError(t, err)
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy3.URL, GlobalGetRootURL())
|
||||
}
|
||||
|
||||
func TestProxyManager_UseProxy_RevertAfterTime(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.useDuration = time.Second
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy.URL, url)
|
||||
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy.URL, GlobalGetRootURL())
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
require.Equal(t, globalOriginalURL, GlobalGetRootURL())
|
||||
}
|
||||
|
||||
func TestProxyManager_UseProxy_RevertIfProxyStopsWorkingAndOriginalAPIIsReachable(t *testing.T) {
|
||||
// Don't block the API here because we want it to be working so the test can find it.
|
||||
defer unblockAPI()
|
||||
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy.URL}, nil }
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxy.URL, url)
|
||||
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy.URL, GlobalGetRootURL())
|
||||
|
||||
// Simulate that the proxy stops working.
|
||||
proxy.Close()
|
||||
time.Sleep(proxyLookupWait)
|
||||
|
||||
// We should now find the original API URL if it is working again.
|
||||
url, err = p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, globalOriginalURL, url)
|
||||
|
||||
p.useProxy(url)
|
||||
require.Equal(t, globalOriginalURL, GlobalGetRootURL())
|
||||
}
|
||||
|
||||
func TestProxyManager_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBlocked(t *testing.T) {
|
||||
blockAPI()
|
||||
defer unblockAPI()
|
||||
|
||||
proxy1 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy1.Close()
|
||||
proxy2 := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer proxy2.Close()
|
||||
|
||||
p := newProxyManager([]string{"not used"}, "not used")
|
||||
p.dohLookup = func(q, p string) ([]string, error) { return []string{proxy1.URL, proxy2.URL}, nil }
|
||||
|
||||
// Find a proxy.
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy1.URL, GlobalGetRootURL())
|
||||
|
||||
// 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 = p.findProxy()
|
||||
require.NoError(t, err)
|
||||
p.useProxy(url)
|
||||
require.Equal(t, proxy2.URL, GlobalGetRootURL())
|
||||
}
|
||||
|
||||
func TestProxyManager_DoHLookup_Quad9(t *testing.T) {
|
||||
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
records, err := p.dohLookup(TestDoHQuery, TestQuad9Provider)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestProxyManager_DoHLookup_Google(t *testing.T) {
|
||||
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
records, err := p.dohLookup(TestDoHQuery, TestGoogleProvider)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestProxyManager_DoHLookup_FindProxy(t *testing.T) {
|
||||
p := newProxyManager([]string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
url, err := p.findProxy()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, url)
|
||||
}
|
||||
|
||||
func TestProxyManager_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) {
|
||||
p := newProxyManager([]string{"https://unreachable", TestGoogleProvider}, TestDoHQuery)
|
||||
|
||||
url, err := p.findProxy()
|
||||
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 = globalOriginalURL
|
||||
|
||||
// blockAPI prevents tests from reaching the standard API, forcing them to find a proxy.
|
||||
func blockAPI() {
|
||||
globalSetRootURL("")
|
||||
globalOriginalURL = ""
|
||||
}
|
||||
|
||||
// unblockAPI allow tests to reach the standard API again.
|
||||
func unblockAPI() {
|
||||
globalOriginalURL = testAPIURLBackup
|
||||
globalSetRootURL(globalOriginalURL)
|
||||
}
|
||||
90
pkg/pmapi/req.go
Normal file
90
pkg/pmapi/req.go
Normal file
@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2020 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 NewRequest(method, path string, body io.Reader) (req *http.Request, err error) {
|
||||
req, err = http.NewRequest(method, GlobalGetRootURL()+path, body)
|
||||
if req != nil {
|
||||
req.Header.Set("User-Agent", CurrentUserAgent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NewJSONRequest create a new JSON request.
|
||||
func NewJSONRequest(method, path string, body interface{}) (*http.Request, error) {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
req, err := 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 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 = NewRequest(method, path, pr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", w.FormDataContentType())
|
||||
return
|
||||
}
|
||||
72
pkg/pmapi/res.go
Normal file
72
pkg/pmapi/res.go
Normal file
@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2020 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
|
||||
|
||||
// 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.ResError == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if res.Code == ForceUpgradeBadAPIVersion ||
|
||||
res.Code == ForceUpgradeInvalidAPI ||
|
||||
res.Code == ForceUpgradeBadAppVersion {
|
||||
return ErrUpgradeApplication
|
||||
}
|
||||
|
||||
if res.Code == APIOffline {
|
||||
return ErrAPINotReachable
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
173
pkg/pmapi/sentry.go
Normal file
173
pkg/pmapi/sentry.go
Normal file
@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/getsentry/raven-go"
|
||||
)
|
||||
|
||||
const fileParseError = "[file parse error]"
|
||||
|
||||
var isGoroutine = regexp.MustCompile("^goroutine [[:digit:]]+.*") //nolint[gochecknoglobals]
|
||||
|
||||
// SentryThreads implements standard sentry thread report.
|
||||
type SentryThreads struct {
|
||||
Values []Thread `json:"values"`
|
||||
}
|
||||
|
||||
// Class specifier.
|
||||
func (s *SentryThreads) Class() string { return "threads" }
|
||||
|
||||
// Thread wraps a single stacktrace.
|
||||
type Thread struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Crashed bool `json:"crashed"`
|
||||
Stacktrace *raven.Stacktrace `json:"stacktrace"`
|
||||
}
|
||||
|
||||
// TraceAllRoutines traces all goroutines and saves them to the current object.
|
||||
func (s *SentryThreads) TraceAllRoutines() {
|
||||
s.Values = []Thread{}
|
||||
goroutines := &strings.Builder{}
|
||||
_ = pprof.Lookup("goroutine").WriteTo(goroutines, 2)
|
||||
|
||||
thread := Thread{ID: -1}
|
||||
var frame *raven.StacktraceFrame
|
||||
for _, v := range strings.Split(goroutines.String(), "\n") {
|
||||
// Ignore empty lines.
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// New routine.
|
||||
if isGoroutine.MatchString(v) {
|
||||
if thread.ID >= 0 {
|
||||
s.Values = append(s.Values, thread)
|
||||
}
|
||||
thread = Thread{ID: thread.ID + 1, Name: v, Crashed: thread.ID == -1, Stacktrace: &raven.Stacktrace{Frames: []*raven.StacktraceFrame{}}}
|
||||
continue
|
||||
}
|
||||
|
||||
// New function.
|
||||
if frame == nil {
|
||||
frame = &raven.StacktraceFrame{Function: v}
|
||||
continue
|
||||
}
|
||||
|
||||
// Set filename and add frame.
|
||||
if frame.Filename == "" {
|
||||
fld := strings.Fields(v)
|
||||
if len(fld) != 2 {
|
||||
frame.Filename = fileParseError
|
||||
frame.AbsolutePath = v
|
||||
} else {
|
||||
frame.Filename = fld[0]
|
||||
sp := strings.Split(fld[0], ":")
|
||||
if len(sp) > 1 {
|
||||
i, err := strconv.Atoi(sp[len(sp)-1])
|
||||
if err == nil {
|
||||
frame.Filename = strings.Join(sp[:len(sp)-1], ":")
|
||||
frame.Lineno = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if frame.AbsolutePath == "" && frame.Filename != fileParseError {
|
||||
frame.AbsolutePath = frame.Filename
|
||||
if sp := strings.Split(frame.Filename, "/"); len(sp) > 1 {
|
||||
frame.Filename = sp[len(sp)-1]
|
||||
}
|
||||
}
|
||||
thread.Stacktrace.Frames = append([]*raven.StacktraceFrame{frame}, thread.Stacktrace.Frames...)
|
||||
frame = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Add last thread.
|
||||
s.Values = append(s.Values, thread)
|
||||
}
|
||||
|
||||
func findPanicSender(s *SentryThreads, err error) string {
|
||||
out := "error nil"
|
||||
if err != nil {
|
||||
out = err.Error()
|
||||
}
|
||||
for _, thread := range s.Values {
|
||||
if !thread.Crashed {
|
||||
continue
|
||||
}
|
||||
for i, fr := range thread.Stacktrace.Frames {
|
||||
if strings.HasSuffix(fr.Filename, "panic.go") && strings.HasPrefix(fr.Function, "panic") {
|
||||
// Next frame if any.
|
||||
j := 0
|
||||
if i > j {
|
||||
j = i - 1
|
||||
}
|
||||
|
||||
// Directory and filename.
|
||||
fname := thread.Stacktrace.Frames[j].AbsolutePath
|
||||
if sp := strings.Split(fname, "/"); len(sp) > 2 {
|
||||
fname = strings.Join(sp[len(sp)-2:], "/")
|
||||
}
|
||||
|
||||
// Line number.
|
||||
if ln := thread.Stacktrace.Frames[j].Lineno; ln > 0 {
|
||||
fname = fmt.Sprintf("%s:%d", fname, ln)
|
||||
}
|
||||
|
||||
out = fmt.Sprintf("%s: %s", fname, out)
|
||||
break // Just first panic.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReportSentryCrash reports a sentry crash with stacktrace from all goroutines.
|
||||
func (c *Client) ReportSentryCrash(reportErr error) (err error) {
|
||||
if reportErr == nil {
|
||||
return
|
||||
}
|
||||
tags := map[string]string{
|
||||
"OS": runtime.GOOS,
|
||||
"Client": c.config.ClientID,
|
||||
"Version": c.config.AppVersion,
|
||||
"UserAgent": CurrentUserAgent,
|
||||
"UserID": c.userID,
|
||||
}
|
||||
|
||||
threads := &SentryThreads{}
|
||||
threads.TraceAllRoutines()
|
||||
errorWithFile := findPanicSender(threads, reportErr)
|
||||
packet := raven.NewPacket(errorWithFile, threads)
|
||||
|
||||
eventID, ch := raven.Capture(packet, tags)
|
||||
if err = <-ch; err == nil {
|
||||
c.log.Warn("Reported error with id: ", eventID)
|
||||
} else {
|
||||
c.log.Errorf("Can not report `%s` due to `%s`", reportErr.Error(), err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
65
pkg/pmapi/sentry_test.go
Normal file
65
pkg/pmapi/sentry_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"testing"
|
||||
|
||||
"github.com/getsentry/raven-go"
|
||||
)
|
||||
|
||||
func TestSentryCrashReport(t *testing.T) {
|
||||
c := NewClient(testClientConfig, "bridgetest")
|
||||
if err := c.ReportSentryCrash(errors.New("Testing crash report - api proxy; goroutines with threads, find origin")); err != nil {
|
||||
t.Fatal("Expected no error while report, but have", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SentryThreads) TraceAllRoutinesTest() {
|
||||
s.Values = []Thread{
|
||||
{
|
||||
ID: 0,
|
||||
Name: "goroutine 20 [running]",
|
||||
Crashed: true,
|
||||
Stacktrace: &raven.Stacktrace{
|
||||
Frames: []*raven.StacktraceFrame{
|
||||
{
|
||||
Filename: "/home/dev/build/go-1.10.2/go/src/runtime/pprof/pprof.go",
|
||||
Function: "runtime/pprof.writeGoroutineStacks(0x9b7de0, 0xc4203e2900, 0xd0, 0xd0)",
|
||||
Lineno: 650,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Name: "goroutine 20 [chan receive]",
|
||||
Crashed: false,
|
||||
Stacktrace: &raven.Stacktrace{
|
||||
Frames: []*raven.StacktraceFrame{
|
||||
{
|
||||
Filename: "/home/dev/build/go-1.10.2/go/src/testing/testing.go",
|
||||
Function: "testing.(*T).Run(0xc4203e42d0, 0x90f445, 0x15, 0x97d358, 0x47a501)",
|
||||
Lineno: 825,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
166
pkg/pmapi/server_test.go
Normal file
166
pkg/pmapi/server_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2020 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"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
var (
|
||||
colRed = "\033[1;31m"
|
||||
colNon = "\033[0;39m"
|
||||
reHTTPCode = regexp.MustCompile(`(HTTP|get|post|put|delete)_(\d{3}).*.json`)
|
||||
)
|
||||
|
||||
// Assert fails the test if the condition is false.
|
||||
func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
|
||||
if !condition {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
vv := []interface{}{filepath.Base(file), line, colRed}
|
||||
vv = append(vv, v...)
|
||||
vv = append(vv, colNon)
|
||||
fmt.Printf("%s:%d: %s"+msg+"%s\n\n", vv...)
|
||||
tb.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Ok fails the test if an err is not nil.
|
||||
func Ok(tb testing.TB, err error) {
|
||||
if err != nil {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
fmt.Printf("%s:%d: %sunexpected error: %s%s\n\n", filepath.Base(file), line, colRed, err.Error(), colNon)
|
||||
tb.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Equals fails the test if exp is not equal to act.
|
||||
func Equals(tb testing.TB, exp, act interface{}) {
|
||||
if !reflect.DeepEqual(exp, act) {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
fmt.Printf("%s:%d:\n\n%s\texp: %#v\n\n\tgot: %#v%s\n\n", filepath.Base(file), line, colRed, exp, act, colNon)
|
||||
tb.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServer is old function and should be replaced everywhere by newTestServerCallbacks.
|
||||
func newTestServer(h http.Handler) (*httptest.Server, *Client) {
|
||||
s := httptest.NewServer(h)
|
||||
RootURL = s.URL
|
||||
|
||||
return s, newTestClient()
|
||||
}
|
||||
|
||||
func newTestServerCallbacks(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)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqNum++
|
||||
if reqNum > len(callbacks) {
|
||||
fmt.Printf(
|
||||
"%s:%d: %sServer was requeted %d times which is more requests than expected %d%s\n\n",
|
||||
file, line, colRed, reqNum, len(callbacks), colNon,
|
||||
)
|
||||
tb.FailNow()
|
||||
}
|
||||
response := callbacks[reqNum-1](tb, w, r)
|
||||
if response != "" {
|
||||
writeJSONResponsefromFile(tb, w, response, reqNum-1)
|
||||
}
|
||||
}))
|
||||
RootURL = server.URL
|
||||
finish := func() {
|
||||
server.CloseClientConnections() // Closing without waiting for finishing requests.
|
||||
if reqNum != len(callbacks) {
|
||||
fmt.Printf(
|
||||
"%s:%d: %sServer was requested %d times but expected to be %d times%s\n\n",
|
||||
file, line, colRed, reqNum, len(callbacks), colNon,
|
||||
)
|
||||
tb.Error("server failed")
|
||||
}
|
||||
}
|
||||
return finish, newTestClient()
|
||||
}
|
||||
|
||||
func checkMethodAndPath(r *http.Request, method, path string) error {
|
||||
var result *multierror.Error
|
||||
if err := checkHeader(r.Header, "x-pm-appversion", "GoPMAPI_1.0.14"); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
if err := checkHeader(r.Header, "x-pm-apiversion", "3"); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
if r.Method != method {
|
||||
err := fmt.Errorf("Invalid request method expected %v, got %v", method, r.Method)
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
if r.URL.RequestURI() != path {
|
||||
err := fmt.Errorf("Invalid request path expected %v, got %v", path, r.URL.RequestURI())
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
func httpResponse(code int) string {
|
||||
return fmt.Sprintf("HTTP_%d.json", code)
|
||||
}
|
||||
|
||||
func writeJSONResponsefromFile(tb testing.TB, w http.ResponseWriter, response string, reqNum int) {
|
||||
if match := reHTTPCode.FindAllSubmatch([]byte(response), -1); len(match) != 0 {
|
||||
httpCode, err := strconv.Atoi(string(match[0][len(match[0])-1]))
|
||||
Ok(tb, err)
|
||||
w.WriteHeader(httpCode)
|
||||
}
|
||||
f, err := os.Open("./testdata/routes/" + response)
|
||||
Ok(tb, err)
|
||||
w.Header().Set("content-type", "application/json;charset=utf-8")
|
||||
w.Header().Set("x-test-pmapi-response", fmt.Sprintf("%s:%d", tb.Name(), reqNum))
|
||||
_, err = io.Copy(w, f)
|
||||
Ok(tb, err)
|
||||
}
|
||||
|
||||
func checkHeader(h http.Header, field, exp string) error {
|
||||
val := h.Get(field)
|
||||
if val != exp {
|
||||
msg := "wrong field %s expected %q but have %q"
|
||||
return fmt.Errorf(msg, field, exp, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAuthReq(r *http.Request, uid, token string) error { // nolint[unparam]
|
||||
if err := checkHeader(r.Header, "x-pm-uid", uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkHeader(r.Header, "authorization", "Bearer "+token); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
118
pkg/pmapi/settings.go
Normal file
118
pkg/pmapi/settings.go
Normal file
@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2020 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserSettings gets general settings.
|
||||
func (c *Client) GetUserSettings() (settings UserSettings, err error) {
|
||||
req, err := 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()
|
||||
}
|
||||
|
||||
type MailSettings struct {
|
||||
DisplayName string
|
||||
Signature string `json:",omitempty"`
|
||||
Theme string `json:",omitempty"`
|
||||
AutoSaveContacts int
|
||||
AutoWildcardSearch int
|
||||
ComposerMode int
|
||||
MessageButtons int
|
||||
ShowImages int
|
||||
ShowMoved int
|
||||
ViewMode int
|
||||
ViewLayout int
|
||||
SwipeLeft int
|
||||
SwipeRight int
|
||||
AlsoArchive int
|
||||
Hotkeys int
|
||||
PMSignature int
|
||||
ImageProxy int
|
||||
TLS int
|
||||
RightToLeft int
|
||||
AttachPublicKey int
|
||||
Sign int
|
||||
PGPScheme int
|
||||
PromptPin int
|
||||
Autocrypt int
|
||||
NumMessagePerPage int
|
||||
DraftMIMEType string
|
||||
ReceiveMIMEType string
|
||||
ShowMIMEType string
|
||||
|
||||
// Undocumented -- there's only `null` in example:
|
||||
// AutoResponder string
|
||||
}
|
||||
|
||||
// GetMailSettings gets contact details specified by contact ID.
|
||||
func (c *Client) GetMailSettings() (settings MailSettings, err error) {
|
||||
req, err := NewRequest("GET", "/settings/mail", nil)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Res
|
||||
MailSettings MailSettings
|
||||
}
|
||||
|
||||
if err = c.DoJSON(req, &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return res.MailSettings, res.Err()
|
||||
}
|
||||
22
pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON
vendored
Normal file
22
pkg/pmapi/testdata/keyring_addressKeysPrimaryHasToken_JSON
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
|
||||
"Primary": 1,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
|
||||
},
|
||||
{
|
||||
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
|
||||
"Primary": 0,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
|
||||
"Token": null,
|
||||
"Signature": null
|
||||
}
|
||||
]
|
||||
22
pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON
vendored
Normal file
22
pkg/pmapi/testdata/keyring_addressKeysSecondaryHasToken_JSON
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
|
||||
"Primary": 1,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
|
||||
"Token": null,
|
||||
"Signature": null
|
||||
},
|
||||
{
|
||||
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
|
||||
"Primary": 0,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
|
||||
}
|
||||
]
|
||||
12
pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON
vendored
Normal file
12
pkg/pmapi/testdata/keyring_addressKeysWithTokens_JSON
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"ID": "hKRtZeTDhvzfAaycb5BOVx6Y3hc3gs4QvET8H_YZBTwAQBPp3h6FI4nnkJePCYuM9CG0zf7TQzOJeB2rPi0YmQ==",
|
||||
"Primary": 1,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nxcMGBFzGzj4BCADh9uuhG3mDhlbFMgQpvP6AReyJYa6KSV9a6AJsNI0/DkVz\nFenIodav/r4RBgSoblqxCa/QH6galbX0eB8Vtxx4xTXCcHHZzTM9oAb4s8r6\ndGchNsSkoLspVwmc2fYfDRCTcapmeza7Jym8gD9wFUSWSwpC49jDeT6VIcOd\n+qNXgIJX1V36BOAB53lKenMYnxfzzAyJDeL/sQ/SWE9h/X+wGdkVmk7gk3pa\nQ9URoxAf7MO808hHyQYcjTV4tOHD/9k+AZC6sqmr2KAefYCxCMoCDj1wQp3J\nN6MfK2Zvc1r5KFQM2YzUXrMNrycAjo7gGeZnsvz758Q+8ouSP7iXGRl/ABEB\nAAH+CQMIQt3QQIJZYjRgTVgIQNSEkhMn4GJ5lQQPBXD0/YyK3xP0bm/bsX8S\nz9dAm1nkP0l8Q9z3lR6OcAX2O/KsqJdQGmJEZAaadz2je+EEWo1FDYaL76E6\n6z+AuteP8UtP61jBt3pTfIlhW8o7o2SlM1lvytpvc5FQplZ/iUuwfsIltCiD\n9VDkprNOVrRsjoh8BV4TrrIvU8qga58Aeg7SjOmm+3oMZ7yPTYirr11Tx/m3\nj1uzdEDfiPk4LmvlzSsWwKZuy6fSul+n92+9qN81wmdts/I2ucuKvOINZim4\nlk/p2AOsPjWpAgkefpTVLZnu2IH+VAyaXt1Fl84badXx4N921nPs7ova1Ud5\n2RddBc7b/01DtOyBSWDoNskLGpsc2mqz9kdkwwQKNjChzZc9nmY9M+AIfgT3\n+2DSQIuoJYPX69DKi/bZDwRzoHmiwiHT6Us7qxd6kD1dzCIHTptxwZQp4Tow\nnN6lmtK4S6O2B47+ROn8s0N+EH9GR8F6mvOTayNLH5yicpR3M4Of0ClvFa0G\n+JUKBXIQXUvF03G3nTPU17nLibC81UmbK3zobfbrLfuU2gU+sY+OfE1E/+GO\nSFpZcrkoRqRr39CfLkLk+GjU7RCLNddb5LxgaurVZo5h0Y7Rr8VvOQMWjjl/\nvTAG7gU/HtXi24TijNC0fP6j9w43K4b6t1SZYn5us7RRlFlWGKMSPf5Q9j6/\nryo3xULUQv0lTCQPtfQQ5UWE5ZpQ4Kjt/k5+/YtfuOcMbrDU4qa+H+rrisBu\nko7f4Wn0iYjRwRIuWh1NfUM3rIbNhq7/wonasEFOeFdPwprzMaawx3rL0Pq5\n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\nV0GB3IX8qBHjAfPmqN9JPxBIn3/6ag==\n=U1yD\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwcBMAxW/uQhm1PKxAQf8D4BAoXqnYgoTr7jEiabP9NkeuD6Z88fZDgUzXte5\n6aYMmCtPr0Vef1eDl2x0S0q/YJgR+A5Icmjxk+jG9nMSnRPlAozJfTCXu/Oy\nfkI4CcClGQv7U1EzfknBVNEKmuPO8XlEkGZ5lW/TzWk8ZtQqJsVuqZlZ5qQE\n/p8FNYEZ5RHPhhKlotA+T6XKh23z9mcN/JNEsSNcx+guERbivtTwnd5JpxQJ\nMNWzUYh+K0W7LgxoAo2jevDd4CZ/0sAAViIl+QrShodseV9agbldGUUoIMzn\nHUuIV6VYi7X82eKQXDInrtPc9IHekbDDFoncLnsGGrEqD/8O/qOHHOPwNwU+\n+cHATAOUTDv/ccWq1QEIALl8SNOQBmCuanAceUTSwCkM1fC3Ddqa8ZmnMMyG\nnDWDI7XkFU31CO10lN20/kAWjdN2073B/NT+17cp8fCqbQ1pAFJHpabdqmbI\ntm3pCC5M6otTN+MhjgFYcBuxo0rq9qtuEzz5j4Ub9MIIJTurUHMEPMI462Dc\ndK/d8BvDkU7q67Lkp65vpe9e/pv0lMMrQjdohnTHNgbZhbI/Z5LU0ApD//Ye\ncSC26BRUMJITiGKb9pKGAi0/ig0jJfzykgEarzOsY/v0W7016AMka+NsHuNc\n7Qfyg2LApF5s9Z6aK+Uy851haUk+p+abjBdcWACziAmimVGjlRYm49ra4UJ7\nc6LSeAElM584e4Z3VnqajAJWbmWt2atgBQmcwEBBsdNAtzIagNydMbBBIELA\n5wGUbMXfQxrMOo/Mdac/5EMTHT0/kVQNIBHtpX5SknWvbc5DjIHoH3+BbL87\neaxhKW94hMubKNKT3dbm4PKHtsMiS3TGkFZ3GjvA0Cy/Kw==\n=b1N8\n-----END PGP MESSAGE-----\n",
|
||||
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwsBcBAEBCAAGBQJcxtSkAAoJEGfweL9m9sQefz8IAJhpEOvG7d+PVgq3bEL+\nXzY8yTUKB1ZXEMbcR/uHUWSfu0F8zy4CNtSG5HUVjNxx1xzzJVJcxWB/ljO+\n/bJSFOFexTyNh8i/xU+CiBfm5RhAFTYF9xFwfD3LKp5gaalJAhWhArk1/Wuh\nWTDpFpk39uzBRKNwcnSgiJYPxOjAZxj+w/hhHPwmco5cUwMiMR5MNrfzKf+x\nwX3Cfs9fsiiCDzohBzbK0FFsMnJ8aXNVsDBjEA1KrB7sdyaf8FnaM0RsFRDb\njxtIgfcFBbyNJh4414Unt4AYTIrIWhK4OOXI3AfsJy8p6KRBKQUcUkKcDxab\nPTXOPZsZ+UgQ5MevyVnP1zfCwFwEAQEIAAYFAlzG1KQACgkQm1DMMBzxetx/\nPwf9F04uHtix0zDpP1IvG4VYlor4rjYTdfXqxxiFXHO6MZXJoigS1E71E8r4\nsqZ6PoQ5/xCj2A01KRhuF1Bon3mEZEwaIUuBqTV91sLsVWfoxgyPAYpr6gK/\n1W9JhVNNrVRMGox42LQUjyiq0ESrfWmqC8SuMfZMoUoBZycHicA50RbyOUnT\nLO57ArL3JIVmYtyosaXM3idzmNHmaXSkcGt4cvTVysJZQrneaxmikfm5CH2O\n1z2goLBNnzsbRionoV6gCukZOiM/d14yiyeYtsFJ7u/vodkI5y0M0sF8VnN/\njL9keP4ZpHiJ4MBd6tyIH0pLueDRlurFL3fcHsEzD5SrOA==\n=wGCk\n-----END PGP SIGNATURE-----\n"
|
||||
}
|
||||
]
|
||||
12
pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON
vendored
Normal file
12
pkg/pmapi/testdata/keyring_addressKeysWithoutTokens_JSON
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"ID": "MhF8dxtN5Lz_GruskN3L9kTKWusZHBdhWaxc7w1tgze2qB6uM9AyC6mWRQg1B7WGZ_r-9gn-XC95-IkpNJr0jg==",
|
||||
"Primary": 1,
|
||||
"Flags": 3,
|
||||
"Version": 3,
|
||||
"Activation": null,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\nYYsMDlA=\n=ZwJf\n-----END PGP PRIVATE KEY BLOCK-----",
|
||||
"Token": null,
|
||||
"Signature": null
|
||||
}
|
||||
]
|
||||
62
pkg/pmapi/testdata/keyring_userKey
vendored
Normal file
62
pkg/pmapi/testdata/keyring_userKey
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||
Version: OpenPGP.js v4.4.5
|
||||
Comment: testpassphrase
|
||||
|
||||
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
|
||||
uaSC3IcBmBsj1fNb4eYXElILjQ==
|
||||
=fMOl
|
||||
-----END PGP PRIVATE KEY BLOCK-----
|
||||
10
pkg/pmapi/testdata/keyring_userKey_JSON
vendored
Normal file
10
pkg/pmapi/testdata/keyring_userKey_JSON
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==",
|
||||
"Version": 3,
|
||||
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: OpenPGP.js v0.7.1\r\nComment: http://openpgpjs.org\r\n\r\n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\nqqGRQm3MxoTdgQUShAwbNwNNQR9cInfMnA==\r\n=2wIY\r\n-----END PGP PRIVATE KEY BLOCK-----\r\n",
|
||||
"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353",
|
||||
"Activation": null,
|
||||
"Primary": 1
|
||||
}
|
||||
]
|
||||
3
pkg/pmapi/testdata/routes/HTTP_200.json
vendored
Normal file
3
pkg/pmapi/testdata/routes/HTTP_200.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"Code": 1000
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/HTTP_401.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/HTTP_401.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 5000,
|
||||
"Error": "Status unauthorized"
|
||||
}
|
||||
4
pkg/pmapi/testdata/routes/HTTP_402.json
vendored
Normal file
4
pkg/pmapi/testdata/routes/HTTP_402.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Code": 5000,
|
||||
"Error": "Status payment required"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user