Compare commits

..

22 Commits

Author SHA1 Message Date
51eb2c42cd Other: Bridge London 2.1.3 2022-04-08 07:34:19 +02:00
c94d839fbb Other: debug mac CI 2022-04-07 16:04:23 +00:00
de586e5f12 GODT-1527: Change bug report description. 2022-04-07 16:04:23 +00:00
c32a106898 GODT-1525: Add keybase/go-keychain/secretservice as new keychain helper. 2022-04-06 11:49:27 +00:00
5b20b6a3d0 GODT-1537: Manual in-app update mechanism. 2022-04-04 11:54:34 +02:00
3b07121f08 Other: temporary disable mac build 2022-03-30 14:45:51 +02:00
a53bc4b027 Other: Bridge London 2.1.2 2022-03-24 09:44:05 +01:00
478345e277 GODT-1522: Rebuild macOS keychain notification 2022-03-24 09:08:34 +01:00
0ed78f1ccb GODT-1524: Fix tests. 2022-03-24 08:33:58 +01:00
6671dd38ea GODT-1524: Logout issues with macOS. 2022-03-23 09:50:17 +01:00
2d5ea669a5 GODT-1437 Add new proxy provider (Quad9 with port). 2022-03-21 11:13:29 +00:00
c7eb7234a2 GODT-1507: Enable autostart after Qt setup. 2022-03-07 17:01:18 +01:00
73d1fe2f65 GODT-1516 GODT-1451: KeepassXC is crashing on start. We need to block it until it's fixed. 2022-03-01 14:17:11 +00:00
cf75ea739f GODT-1516: Return notification on missing keychain 2022-03-01 14:17:11 +00:00
c920c53243 GODT-1451: Do not check for gnome keyring to allow other implementations of secret-service API. Thanks to @remgodow. 2022-03-01 14:17:11 +00:00
63379001e3 GODT-1515: Do not crash when bridge users got disconnected. 2022-02-22 16:44:45 +01:00
aa8cc3fc4b GODT-1503 GODT-1492: Improve email validation and username in bug report. 2022-02-21 13:34:57 +01:00
61e4ca5814 Other: Bridge London 2.1.1 2022-02-02 17:16:48 +01:00
8e0693ab03 GODT-1499: Remove message from DB once is not on server any more 2022-01-31 09:40:23 +00:00
a3d2df9d38 GODT-1376: Add first userID to sentry scope. 2022-01-20 12:45:53 +01:00
f9f4ce996d GODT-1375: Add host architecture to sentry reports 2022-01-18 09:52:57 +00:00
fc69b9aabb GODT-1364: Add windows CI machine for tests, and build 2022-01-17 14:49:51 +00:00
82 changed files with 1529 additions and 285 deletions

View File

@ -48,7 +48,7 @@ lint:
tags:
- medium
test:
test-linux:
stage: test
only:
- branches
@ -65,6 +65,14 @@ test:
tags:
- medium
test-windows:
extends: .build-windows-base
stage: test
only:
- branches
script:
- make test
test-integration:
stage: test
only:
@ -96,6 +104,7 @@ build-qml:
- cd internal/frontend/qml
- tar -cvzf ../../../bridge_qml.tgz ./*
.build-base:
stage: build
only:
@ -134,11 +143,10 @@ build-linux-qa:
paths:
- bridge_*.tgz
.build-darwin-base:
extends: .build-base
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- export PATH=/usr/local/bin:$PATH
- export PATH=/usr/local/opt/git/bin:$PATH
- export PATH=/usr/local/opt/make/libexec/gnubin:$PATH
@ -170,6 +178,40 @@ build-darwin-qa:
paths:
- bridge_*.tgz
.build-windows-base:
extends: .build-base
before_script:
- export GOROOT=/c/Go
- export PATH=$GOROOT/bin:$PATH
- export GOARCH=amd64
- export GOPATH=~/go
- export GO111MODULE=on
- export PATH=$GOPATH/bin:$PATH
- export MSYSTEM=
- export PATH=$PATH:/c/grrrQt/5.13.2/mingw73_64/bin
tags:
- windows-bridge
build-windows:
extends: .build-windows-base
artifacts:
name: "bridge-windows-$CI_COMMIT_SHORT_SHA"
paths:
- bridge_*.tgz
build-windows-qa:
extends: .build-windows-base
only:
- web
- branches
script:
- BUILD_TAGS="build_qa" make build
artifacts:
name: "bridge-windows-qa-$CI_COMMIT_SHORT_SHA"
paths:
- bridge_*.tgz
# Stage: MIRROR
mirror-repo:

0
.gitmodules vendored
View File

View File

@ -1,7 +1,9 @@
# Building ProtonMail Bridge and Import-Export app
## Prerequisites
* 64-bit OS (the go-rfc5322 module cannot currently be compiled for 32-bit OSes)
* 64-bit AMD OS:
- the go-rfc5322 module cannot currently be compiled for 32-bit OSes
- the Apple M1 builds are not supported yet due to dependencies
* Go 1.13
* Bash with basic build utils: make, gcc, sed, find, grep, ...
* For Windows it is recommended to use MinGW 64bit shell from [MSYS2](https://www.msys2.org/)

View File

@ -2,6 +2,47 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## [Bridge 2.1.3] London
## Added
GODT-1525: Add keybase/go-keychain/secretservice as new keychain helper.
## Changed
GODT-1527: Change bug report description.
## Fixed
GODT-1537: Manual in-app update mechanism.
## [Bridge 2.1.2] London
## Added
* GODT-1522: Rebuild macOS keychain notification.
* GODT-1437 Add new proxy provider (Quad9 with port).
* GODT-1516: Return notification on missing keychain.
## Changed
* GODT-1451: Do not check for gnome keyring to allow other implementations of secret-service API. Thanks to @remgodow.
* GODT-1516 GODT-1451: KeepassXC is crashing on start. We need to block it until it's fixed.
## Fixed
* GODT-1524: Logout issues with macOS.
* GODT-1503 GODT-1492: Improve email validation and username in bug report.
* GODT-1507: Enable autostart after Qt setup.
* GODT-1515: Do not crash when bridge users got disconnected.
## [Bridge 2.1.1] London
## Added
* GODT-1376: Add first userID to sentry scope.
* GODT-1375: Add host architecture to sentry reports.
* GODT-1364: Add windows CI machine for tests, and build.
### Fixed
* GODT-1499: Remove message from DB once it is not on server any more.
## [Bridge 2.1.0] London
### Fixed

View File

@ -10,7 +10,7 @@ TARGET_OS?=${GOOS}
.PHONY: build build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=2.1.0+git
BRIDGE_APP_VERSION?=2.1.3+git
APP_VERSION:=${BRIDGE_APP_VERSION}
SRC_ICO:=logo.ico
SRC_ICNS:=Bridge.icns
@ -85,7 +85,7 @@ hasher:
${TGZ_TARGET}: ${DEPLOY_DIR}/${TARGET_OS}
rm -f $@
cd ${DEPLOY_DIR}/${TARGET_OS} && tar czf ../../../../$@ .
cd ${DEPLOY_DIR}/${TARGET_OS} && tar -czvf ../../../../$@ .
${DEPLOY_DIR}/linux: ${EXE_TARGET}
cp -pf ./internal/frontend/share/${SRC_SVG} ${DEPLOY_DIR}/linux/logo.svg

View File

@ -53,10 +53,12 @@ the user for a password.
## Keychain
You need to have a keychain in order to run the ProtonMail Bridge. On Mac or
Windows, Bridge uses native credential managers. On Linux, use
[Gnome keyring](https://wiki.gnome.org/Projects/GnomeKeyring/)
Windows, Bridge uses native credential managers. On Linux, use `secret-service` freedesktop.org API
(e.g. [Gnome keyring](https://wiki.gnome.org/Projects/GnomeKeyring/))
or
[pass](https://www.passwordstore.org/).
[pass](https://www.passwordstore.org/). We are working on allowing other secret
services (e.g. KeepassXC), but for now only gnome-keyring is usable without
major problems.
## Environment Variables

103
doc/updates.md Normal file
View File

@ -0,0 +1,103 @@
# Update mechanism of Bridge
There are mulitple options how to change version of application:
* Automatic in-app update
* Manual in-app update
* Manual install
In-app update ends with restarting bridge into new version. Automatic in-app
update is downloading, verifying and installing the new version immediatelly
without user confirmation. For manual in-app update user needs to confirm first.
Update is done from special update file published on website.
The manual installation requires user to download, verify and install manually
using installer for given OS.
The bridge is installed and executed differently for given OS:
* Windows and Linux apps are using launcher mechanism:
* There is system protected installation path which is created on first
install. It contains bridge exe and launcher exe. When users starts
bridge the launcher is executed first. It will check update path compare
version with installed one. The newer version then is then executed.
* Update mechanism means to replace files in update folder which is located
in user space.
* macOS app does not use launcher
* No launcher, only one executable
* In-App udpate replaces the bridge files in installation path directly
```mermaid
flowchart LR
subgraph Frontend
U[User requests<br>version check]
ManIns((Notify user about<br>manual install<br>is needed))
R((Notify user<br>about restart))
ManUp((Notify user about<br>manual update))
NF((Notify user about<br>force update))
ManUp -->|Install| InstFront[Install]
InstFront -->|Ok| R
InstFront -->|Error| ManIns
U --> CheckFront[Check online]
CheckFront -->|Ok| IAFront{Is new version<br>and applicable?}
CheckFront -->|Error| ManIns
IAFront -->|No| Latest((Notify user<br>has latest version))
IAFront -->|Yes| CanInstall{Can update?}
CanInstall -->|No| ManIns
CanInstall -->|Yes| NotifOrInstall{Is automatic<br>update enabled?}
NotifOrInstall -->|Manual| ManUp
end
subgraph Backend
W[Wait for next check]
W --> Check[Check online]
Check --> NV{Has new<br>version?}
Check -->|Error| W
NV -->|No new version| W
IA{Is install<br>applicable?}
NV -->|New version<br>available| IA
IA -->|Local rollout<br>not enough| W
IA -->|Yes| AU{Is automatic\nupdate enabled?}
AU -->|Yes| CanUp{Can update?}
CanUp -->|No| ManIns
CanUp -->|Yes| Ins[Install]
Ins -->|Error| ManIns
Ins -->|Ok| R
AU -->|No| ManUp
ManUp -->|Ignore| W
F[Force update]
F --> NF
end
ManIns --> Web[Open web page]
NF --> Web
ManUp --> Web
R --> Re[Restart]
NF --> Q[Quit bridge]
NotifOrInstall -->|Automatic| W
```
The non-trivial is to combine the update with setting change:
* turn off/on automatic in-app updates
* change from stable to beta or back
_TODO fill flow chart details_
We are not support downgrade functionality. Only some circumstances can lead to
downgrading the app version.
_TODO fill flow chart details_

12
go.mod
View File

@ -28,6 +28,8 @@ require (
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/cucumber/godog v0.12.1
github.com/cucumber/messages-go/v16 v16.0.1
github.com/elastic/go-sysinfo v1.7.1
github.com/elastic/go-windows v1.0.1 // indirect
github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342
github.com/emersion/go-imap-quota v0.0.0-20210203125329-619074823f3c
@ -41,18 +43,22 @@ require (
github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
github.com/getsentry/sentry-go v0.12.0
github.com/go-resty/resty/v2 v2.6.0
github.com/godbus/dbus v4.1.0+incompatible
github.com/golang/mock v1.4.4
github.com/google/go-cmp v0.5.5
github.com/google/uuid v1.1.1
github.com/hashicorp/go-multierror v1.1.0
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d
github.com/keybase/go-keychain v0.0.0
github.com/kr/text v0.2.0 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/miekg/dns v1.1.41
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce
github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/pkg/errors v0.9.1
github.com/prometheus/procfs v0.7.3 // indirect
github.com/ricochet2200/go-disk-usage/du v0.0.0-20210707232629-ac9918953285
github.com/sirupsen/logrus v1.7.0
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
@ -64,8 +70,9 @@ require (
github.com/vmihailenco/msgpack/v5 v5.1.3
go.etcd.io/bbolt v1.3.6
golang.org/x/net v0.0.0-20211008194852-3b03d305991f
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
golang.org/x/sys v0.0.0-20220111092808-5a964db01320
golang.org/x/text v0.3.7
howett.net/plist v1.0.0 // indirect
)
replace (
@ -73,4 +80,5 @@ replace (
github.com/emersion/go-imap => github.com/ProtonMail/go-imap v0.0.0-20201228133358-4db68cea0cac
github.com/emersion/go-message => github.com/ProtonMail/go-message v0.0.0-20210611055058-fabeff2ec753
github.com/jameskeane/bcrypt => github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57
github.com/keybase/go-keychain => github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe
)

27
go.sum
View File

@ -102,6 +102,8 @@ github.com/cucumber/godog v0.12.1/go.mod h1:u6SD7IXC49dLpPN35kal0oYEjsXZWee4pW6T
github.com/cucumber/messages-go/v16 v16.0.0/go.mod h1:EJcyR5Mm5ZuDsKJnT2N9KRnBK30BGjtYotDKpwQ0v6g=
github.com/cucumber/messages-go/v16 v16.0.1 h1:fvkpwsLgnIm0qugftrw2YwNlio+ABe2Iu94Ap8GMYIY=
github.com/cucumber/messages-go/v16 v16.0.1/go.mod h1:EJcyR5Mm5ZuDsKJnT2N9KRnBK30BGjtYotDKpwQ0v6g=
github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe h1:KRj3wdvA9yE92prNmOjS7x5DOqoyjxqdE30qnrmTasc=
github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe/go.mod h1:ZoZU1fnBy3mOLWr3Pg+Y2+nTKtu6ypDte2kZg9HvSwY=
github.com/danieljoos/wincred v1.1.0 h1:3RNcEpBg4IhIChZdFRSdlQt1QjCp1sMAPIrOnm7Yf8g=
github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -113,6 +115,11 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/elastic/go-sysinfo v1.7.1 h1:Wx4DSARcKLllpKT2TnFVdSUJOsybqMYCNQZq1/wO+s0=
github.com/elastic/go-sysinfo v1.7.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0=
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a h1:bMdSPm6sssuOFpIaveu3XGAijMS3Tq2S3EqFZmZxidc=
github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ=
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 h1:5p1t3e1PomYgLWwEwhwEU5kVBwcyAcVrOpexv8AeZx0=
@ -159,6 +166,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -179,6 +188,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
@ -241,6 +251,9 @@ github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0Gqw
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4=
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -256,6 +269,8 @@ github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d h1:gVjhBCfVGl32RIBooOANzfw+0UqX8HU+yPlMv8vypcg=
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d/go.mod h1:W6EbaYmb4RldPn0N3gvVHjY1wmU59kbymhW9NATWhwY=
github.com/keybase/go-keychain v0.0.0-20211119201326-e02f34051621 h1:aMQ7pA4f06yOVXSulygyGvy4xA94fyzjUGs0iqQdMOI=
github.com/keybase/go-keychain v0.0.0-20211119201326-e02f34051621/go.mod h1:enrU/ug069Om7vWxuFE6nikLI2BZNwevMiGSo43Kt5w=
github.com/keybase/go.dbus v0.0.0-20200324223359-a94be52c0b03/go.mod h1:a8clEhrrGV/d76/f9r2I41BwANMihfZYV9C223vaxqE=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@ -347,7 +362,10 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/ricochet2200/go-disk-usage/du v0.0.0-20210707232629-ac9918953285 h1:d54EL9l+XteliUfUCGsEwwuk65dmmxX85VXF+9T6+50=
github.com/ricochet2200/go-disk-usage/du v0.0.0-20210707232629-ac9918953285/go.mod h1:fxIDly1xtudczrZeOOlfaUvd2OPb2qZAPuWdU2BsBTk=
@ -509,6 +527,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -532,11 +551,13 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -547,6 +568,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -622,6 +645,7 @@ gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -635,4 +659,7 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

View File

@ -189,6 +189,8 @@ func New( // nolint[funlen]
cm := pmapi.New(cfg)
sentryReporter.SetClientFromManager(cm)
cm.AddConnectionObserver(pmapi.NewConnectionObserver(
func() { listener.Emit(events.InternetOffEvent, "") },
func() { listener.Emit(events.InternetOnEvent, "") },

View File

@ -108,10 +108,6 @@ func New(
if err := b.SendMetric(metrics.New(metrics.Setup, metrics.FirstStart, metrics.Label(constants.Version))); err != nil {
logrus.WithError(err).Error("Failed to send metric")
}
if err := b.EnableAutostart(); err != nil {
log.WithError(err).Error("Failed to enable autostart")
}
setting.SetBool(settings.FirstStartKey, false)
}

View File

@ -39,6 +39,12 @@ var ErrSizeTooLarge = errors.New("file is too big")
// ReportBug reports a new bug from the user.
func (b *Bridge) ReportBug(osType, osVersion, description, accountName, address, emailClient string, attachLogs bool) error {
if user, err := b.GetUser(address); err == nil {
accountName = user.Username()
} else if users := b.GetUsers(); len(users) > 0 {
accountName = users[0].Username()
}
report := pmapi.ReportBugReq{
OS: osType,
OSVersion: osVersion,

View File

@ -25,81 +25,118 @@ import (
"github.com/stretchr/testify/require"
)
const testPrefFilePath = "/tmp/pref.json"
func TestLoadNoKeyValueStore(t *testing.T) {
pref := newTestEmptyKeyValueStore(t)
require.Equal(t, "", pref.Get("key"))
r := require.New(t)
pref, clean := newTestEmptyKeyValueStore(r)
defer clean()
r.Equal("", pref.Get("key"))
}
func TestLoadBadKeyValueStore(t *testing.T) {
require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"key\":\"value"), 0700))
pref := newKeyValueStore(testPrefFilePath)
require.Equal(t, "", pref.Get("key"))
r := require.New(t)
path, clean := newTmpFile(r)
defer clean()
r.NoError(ioutil.WriteFile(path, []byte("{\"key\":\"MISSING_QUOTES"), 0700))
pref := newKeyValueStore(path)
r.Equal("", pref.Get("key"))
}
func TestKeyValueStoreGet(t *testing.T) {
pref := newTestKeyValueStore(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 TestKeyValueStor(t *testing.T) {
r := require.New(t)
pref, clean := newTestKeyValueStore(r)
defer clean()
r.Equal("value", pref.Get("str"))
r.Equal("42", pref.Get("int"))
r.Equal("true", pref.Get("bool"))
r.Equal("t", pref.Get("falseBool"))
}
func TestKeyValueStoreGetInt(t *testing.T) {
pref := newTestKeyValueStore(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"))
r := require.New(t)
pref, clean := newTestKeyValueStore(r)
defer clean()
r.Equal(0, pref.GetInt("str"))
r.Equal(42, pref.GetInt("int"))
r.Equal(0, pref.GetInt("bool"))
r.Equal(0, pref.GetInt("falseBool"))
}
func TestKeyValueStoreGetBool(t *testing.T) {
pref := newTestKeyValueStore(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"))
r := require.New(t)
pref, clean := newTestKeyValueStore(r)
defer clean()
r.Equal(false, pref.GetBool("str"))
r.Equal(false, pref.GetBool("int"))
r.Equal(true, pref.GetBool("bool"))
r.Equal(false, pref.GetBool("falseBool"))
}
func TestKeyValueStoreSetDefault(t *testing.T) {
pref := newTestEmptyKeyValueStore(t)
r := require.New(t)
pref, clean := newTestEmptyKeyValueStore(r)
defer clean()
pref.setDefault("key", "value")
pref.setDefault("key", "othervalue")
require.Equal(t, "value", pref.Get("key"))
r.Equal("value", pref.Get("key"))
}
func TestKeyValueStoreSet(t *testing.T) {
pref := newTestEmptyKeyValueStore(t)
r := require.New(t)
pref, clean := newTestEmptyKeyValueStore(r)
defer clean()
pref.Set("str", "value")
checkSavedKeyValueStore(t, "{\n\t\"str\": \"value\"\n}")
checkSavedKeyValueStore(r, pref.path, "{\n\t\"str\": \"value\"\n}")
}
func TestKeyValueStoreSetInt(t *testing.T) {
pref := newTestEmptyKeyValueStore(t)
r := require.New(t)
pref, clean := newTestEmptyKeyValueStore(r)
defer clean()
pref.SetInt("int", 42)
checkSavedKeyValueStore(t, "{\n\t\"int\": \"42\"\n}")
checkSavedKeyValueStore(r, pref.path, "{\n\t\"int\": \"42\"\n}")
}
func TestKeyValueStoreSetBool(t *testing.T) {
pref := newTestEmptyKeyValueStore(t)
r := require.New(t)
pref, clean := newTestEmptyKeyValueStore(r)
defer clean()
pref.SetBool("trueBool", true)
pref.SetBool("falseBool", false)
checkSavedKeyValueStore(t, "{\n\t\"falseBool\": \"false\",\n\t\"trueBool\": \"true\"\n}")
checkSavedKeyValueStore(r, pref.path, "{\n\t\"falseBool\": \"false\",\n\t\"trueBool\": \"true\"\n}")
}
func newTestEmptyKeyValueStore(t *testing.T) *keyValueStore {
require.NoError(t, os.RemoveAll(testPrefFilePath))
return newKeyValueStore(testPrefFilePath)
func newTmpFile(r *require.Assertions) (path string, clean func()) {
tmpfile, err := ioutil.TempFile("", "pref.*.json")
r.NoError(err)
defer r.NoError(tmpfile.Close())
return tmpfile.Name(), func() {
r.NoError(os.Remove(tmpfile.Name()))
}
}
func newTestKeyValueStore(t *testing.T) *keyValueStore {
require.NoError(t, ioutil.WriteFile(testPrefFilePath, []byte("{\"str\":\"value\",\"int\":\"42\",\"bool\":\"true\",\"falseBool\":\"t\"}"), 0700))
return newKeyValueStore(testPrefFilePath)
func newTestEmptyKeyValueStore(r *require.Assertions) (*keyValueStore, func()) {
path, clean := newTmpFile(r)
return newKeyValueStore(path), clean
}
func checkSavedKeyValueStore(t *testing.T, expected string) {
data, err := ioutil.ReadFile(testPrefFilePath)
require.NoError(t, err)
require.Equal(t, expected, string(data))
func newTestKeyValueStore(r *require.Assertions) (*keyValueStore, func()) {
path, clean := newTmpFile(r)
r.NoError(ioutil.WriteFile(path, []byte("{\"str\":\"value\",\"int\":\"42\",\"bool\":\"true\",\"falseBool\":\"t\"}"), 0700))
return newKeyValueStore(path), clean
}
func checkSavedKeyValueStore(r *require.Assertions, path, expected string) {
data, err := ioutil.ReadFile(path)
r.NoError(err)
r.Equal(expected, string(data))
}

View File

@ -101,10 +101,10 @@ func (f *frontendCLI) notifyNeedUpgrade() {
f.Println("Please download and install the newest version of application from", version.LandingPage)
}
func (f *frontendCLI) notifyCredentialsError() { // nolint[unused]
func (f *frontendCLI) notifyCredentialsError() {
// Print in 80-column width.
f.Println("ProtonMail Bridge is not able to detect a supported password manager")
f.Println("(pass, gnome-keyring). Please install and set up a supported password manager")
f.Println("(secret-service or pass). Please install and set up a supported password manager")
f.Println("and restart the application.")
}

View File

@ -133,7 +133,7 @@ QtObject {
return Qt.point(_x, _y)
}
// fir to the right
// fit to the right
_x = iconRect.right
if (isInInterval(_x, screenRect.left, screenRect.right - width)) {
// position preferebly in the vertical center but bound to the screen rect

View File

@ -55,6 +55,26 @@ Window {
function getCursorPos() {
return BridgePreview.getCursorPos()
}
function restart() {
root.quit()
console.log("Restarting....")
root.openBridge()
}
function openBridge() {
bridge = bridgeComponent.createObject()
var showSetupGuide = false
if (showSetupGuide) {
var newUserObject = root.userComponent.createObject(root)
newUserObject.username = "LerooooyJenkins@protonmail.com"
newUserObject.loggedIn = true
newUserObject.setupGuideSeen = false
root.users.append( { object: newUserObject } )
}
}
function quit() {
if (bridge !== undefined && bridge !== null) {
bridge.destroy()
@ -367,18 +387,7 @@ Window {
text: "Open Bridge"
enabled: bridge === undefined || bridge === null
onClicked: {
bridge = bridgeComponent.createObject()
var showSetupGuide = false
if (showSetupGuide) {
var newUserObject = root.userComponent.createObject(root)
newUserObject.username = "LerooooyJenkins@protonmail.com"
newUserObject.loggedIn = true
newUserObject.setupGuideSeen = false
root.users.append( { object: newUserObject } )
}
}
onClicked: root.openBridge()
}
Button {
@ -589,7 +598,15 @@ Window {
text: "No keychain"
colorScheme: root.colorScheme
onClicked: {
root.hasNoKeychain()
root.notifyHasNoKeychain()
}
}
Button {
text: "Rebuild keychain"
colorScheme: root.colorScheme
onClicked: {
root.notifyRebuildKeychain()
}
}
}
@ -712,6 +729,9 @@ Window {
console.log("check updates")
}
signal checkUpdatesFinished()
function installUpdate() {
console.log("manuall install update triggered")
}
property bool isDiskCacheEnabled: true
@ -731,7 +751,19 @@ Window {
property bool isAutomaticUpdateOn : true
function toggleAutomaticUpdate(makeItActive) {
console.debug("-> silent updates", makeItActive, root.isAutomaticUpdateOn)
root.isAutomaticUpdateOn = makeItActive
var callback = function () {
root.isAutomaticUpdateOn = makeItActive;
console.debug("-> CHANGED silent updates", makeItActive, root.isAutomaticUpdateOn)
}
atimer.onTriggered.connect(callback)
atimer.restart()
}
Timer {
id: atimer
interval: 2000
running: false
repeat: false
}
property bool isAutostartOn : true // Example of settings with loading state
@ -815,7 +847,8 @@ Window {
root.changeKeychainFinished()
}
signal changeKeychainFinished()
signal hasNoKeychain()
signal notifyHasNoKeychain()
signal notifyRebuildKeychain()
signal noActiveKeyForRecipient(string email)
signal showMainWindow()

View File

@ -127,19 +127,10 @@ SettingsView {
}
TextEdit {
text: {
var address = "bridge@protonmail.com"
var mailTo = `<a href="mailto://${address}">${address}</a>`
return "<style>a:link { color: " + root.colorScheme.interaction_norm + "; }</style>" +qsTr(
"These reports are not end-to-end encrypted. In case of sensitive information, contact us at %1."
).arg(mailTo)
}
onLinkActivated: Qt.openUrlExternally(link)
textFormat: Text.RichText
text: qsTr("Reports are not end-to-end encrypted, please do not send any sensitive information.")
readOnly: true
Layout.fillWidth: true
color: root.colorScheme.text_weak
font.family: ProtonStyle.font_family
@ -181,7 +172,7 @@ SettingsView {
}
function isValidEmail(text){
var reEmail = /\w+@\w+\.\w+/
var reEmail = /^[^@]+@[^@]+\.[A-Za-z]+\s*$/
return reEmail.test(text)
}

View File

@ -182,6 +182,7 @@ ApplicationWindow {
colorScheme: root.colorScheme
notifications: root.notifications
mainWindow: root
backend: root.backend
}
function showLocalCacheSettings() { contentWrapper.showLocalCacheSettings() }

View File

@ -25,6 +25,7 @@ import Notifications 1.0
Item {
id: root
property var backend
property ColorScheme colorScheme
property var notifications
@ -51,8 +52,11 @@ Item {
notification: root.notifications.updateManualReady
Switch {
id:autoUpdate
colorScheme: root.colorScheme
text: qsTr("Update automatically in the future")
checked: root.backend.isAutomaticUpdateOn
onClicked: root.backend.toggleAutomaticUpdate(autoUpdate.checked)
}
}
@ -115,4 +119,9 @@ Item {
colorScheme: root.colorScheme
notification: root.notifications.noKeychain
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.rebuildKeychain
}
}

View File

@ -73,7 +73,8 @@ QtObject {
root.enableLocalCache,
root.resetBridge,
root.deleteAccount,
root.noKeychain
root.noKeychain,
root.rebuildKeychain
]
// Connection
@ -165,8 +166,9 @@ QtObject {
}
property Notification updateManualError: Notification {
description: qsTr("Bridge couldnt update")
brief: description
title: qsTr("Bridge couldnt update")
brief: title
description: qsTr("Please follow manual installation in order to update Bridge.")
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Warning
group: Notifications.Group.Update
@ -191,7 +193,7 @@ QtObject {
text: qsTr("Remind me later")
onTriggered: {
root.updateManualReady.active = false
root.updateManualError.active = false
}
}
]
@ -272,7 +274,7 @@ QtObject {
onTriggered: {
root.backend.quit()
root.updateForce.active = false
root.updateForceError.active = false
}
}
]
@ -870,7 +872,7 @@ QtObject {
property Notification noKeychain: Notification {
title: qsTr("No keychain available")
description: qsTr("Bridge is not able to detected a supported password manager (pass, gnome-keyring). Please install and setup supported password manager and restart the application.")
description: qsTr("Bridge is not able to detect a supported password manager (pass or secret-service). Please install and setup supported password manager and restart the application.")
brief: title
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
@ -879,7 +881,7 @@ QtObject {
Connections {
target: root.backend
onHasNoKeychain: {
onNotifyHasNoKeychain: {
root.noKeychain.active = true
}
}
@ -891,6 +893,45 @@ QtObject {
onTriggered: {
root.backend.quit()
}
},
Action {
text: qsTr("Restart Bridge")
onTriggered: {
root.backend.restart()
}
}
]
}
property Notification rebuildKeychain: Notification {
title: qsTr("Your macOS keychain might be corrupted")
description: qsTr("Bridge is not able to access your macOS keychain. Please consult the instructions on our support page.")
brief: title
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
group: Notifications.Group.Dialogs | Notifications.Group.Configuration
property var supportLink: "https://protonmail.com/support/knowledge-base/macos-keychain-corrupted"
Connections {
target: root.backend
onNotifyRebuildKeychain: {
console.log("notifications")
root.rebuildKeychain.active = true
}
}
action: [
Action {
text: qsTr("Open the support page")
onTriggered: {
Qt.openUrlExternally(root.rebuildKeychain.supportLink)
root.backend.quit()
}
}
]
}

View File

@ -61,6 +61,7 @@ type FrontendQt struct {
log *logrus.Entry
initializing sync.WaitGroup
initializationDone sync.Once
firstTimeAutostart sync.Once
app *widgets.QApplication
engine *qml.QQmlEngine
@ -152,7 +153,7 @@ func (f *FrontendQt) NotifySilentUpdateInstalled() {
}
func (f *FrontendQt) NotifySilentUpdateError(err error) {
f.log.WithError(err).Warn("Update failed, asking for manual.")
f.log.WithError(err).Warn("In-app update failed, asking for manual.")
f.qml.UpdateManualError()
}

View File

@ -26,6 +26,7 @@ import (
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/pkg/keychain"
)
func (f *FrontendQt) watchEvents() {
@ -64,7 +65,11 @@ func (f *FrontendQt) watchEvents() {
if strings.Contains(errorDetails, "SMTP failed") {
f.qml.PortIssueSMTP()
}
case <-credentialsErrorCh:
case reason := <-credentialsErrorCh:
if reason == keychain.ErrMacKeychainRebuild.Error() {
f.qml.NotifyRebuildKeychain()
continue
}
f.qml.NotifyHasNoKeychain()
case email := <-noActiveKeyForRecipientCh:
f.qml.NoActiveKeyForRecipient(email)

View File

@ -53,7 +53,7 @@ func (f *FrontendQt) reportBug(description, address, emailClient string, include
core.QSysInfo_ProductType(),
core.QSysInfo_PrettyProductName(),
description,
"Unknown account",
address,
address,
emailClient,
includeLogs,

View File

@ -44,6 +44,8 @@ func (f *FrontendQt) initiateQtApplication() error {
core.QCoreApplication_SetApplicationName(f.programName)
core.QCoreApplication_SetApplicationVersion(f.programVersion)
core.QCoreApplication_SetOrganizationName("Proton AG")
core.QCoreApplication_SetOrganizationDomain("proton.ch")
// High DPI scaling for windows.
core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, false)

View File

@ -76,6 +76,15 @@ func (f *FrontendQt) changeLocalCache(enableDiskCache bool, diskCachePath *core.
}
func (f *FrontendQt) setIsAutostartOn() {
// GODT-1507 Windows: autostart needs to be created after Qt is initialized.
f.firstTimeAutostart.Do(func() {
if !f.bridge.IsFirstStart() {
return
}
if err := f.bridge.EnableAutostart(); err != nil {
f.log.WithError(err).Error("Failed to enable autostart")
}
})
f.qml.SetIsAutostartOn(f.bridge.IsAutostartEnabled())
}

View File

@ -25,6 +25,7 @@ import (
"github.com/ProtonMail/proton-bridge/internal/config/settings"
"github.com/ProtonMail/proton-bridge/internal/updater"
"github.com/pkg/errors"
)
var checkingUpdates = sync.Mutex{}
@ -62,9 +63,18 @@ func (f *FrontendQt) checkUpdatesAndNotify(isRequestFromUser bool) {
if !f.updater.CanInstall(f.newVersionInfo) {
f.log.Debug("A manual update is required")
f.qml.UpdateManualReady(f.newVersionInfo.Version.String())
f.qml.UpdateManualError()
return
}
if f.settings.GetBool(settings.AutoUpdateKey) {
// NOOP will update eventually
return
}
if isRequestFromUser {
f.qml.UpdateManualReady(f.newVersionInfo.Version.String())
}
}
func (f *FrontendQt) updateForce() {
@ -113,3 +123,26 @@ func (f *FrontendQt) toggleBeta(makeItEnabled bool) {
// Immediately check the updates to set the correct landing page link.
f.checkUpdates()
}
func (f *FrontendQt) installUpdate() {
checkingUpdates.Lock()
defer checkingUpdates.Unlock()
if !f.updater.CanInstall(f.newVersionInfo) {
f.log.Warning("Skipping update installation, current version too old")
f.qml.UpdateManualError()
return
}
if err := f.updater.InstallUpdate(f.newVersionInfo); err != nil {
if errors.Cause(err) == updater.ErrDownloadVerify {
f.log.WithError(err).Warning("Skipping update installation due to temporary error")
} else {
f.log.WithError(err).Error("The update couldn't be installed")
f.qml.UpdateManualError()
}
return
}
f.qml.UpdateSilentRestartNeeded()
}

View File

@ -81,6 +81,7 @@ type QMLBackend struct {
_ func() `signal:"updateIsLatestVersion"`
_ func() `slot:"checkUpdates"`
_ func() `signal:"checkUpdatesFinished"`
_ func() `slot:"installUpdate"`
_ bool `property:"isDiskCacheEnabled"`
_ core.QUrl `property:"diskCachePath"`
@ -142,6 +143,7 @@ type QMLBackend struct {
_ func(keychain string) `slot:"changeKeychain"`
_ func() `signal:"changeKeychainFinished"`
_ func() `signal:"notifyHasNoKeychain"`
_ func() `signal:"notifyRebuildKeychain"`
_ func(email string) `signal:noActiveKeyForRecipient`
_ func() `signal:showMainWindow`
@ -212,6 +214,13 @@ func (q *QMLBackend) setup(f *FrontendQt) {
}()
})
q.ConnectInstallUpdate(func() {
go func() {
defer f.panicHandler.HandlePanic()
f.installUpdate()
}()
})
f.setIsDiskCacheEnabled()
f.setDiskCachePath()
q.ConnectChangeLocalCache(func(e bool, d *core.QUrl) {

View File

@ -0,0 +1,49 @@
// Copyright (c) 2022 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/>.
//go:build darwin
// +build darwin
package sentry
import (
"github.com/elastic/go-sysinfo"
"golang.org/x/sys/unix"
)
const translatedProcDarwin = "sysctl.proc_translated"
func getHostAarch() string {
host, err := sysinfo.Host()
if err != nil {
return "not-detected"
}
// It is not possible to retrieve real hardware architecture once using
// rosetta. But it is possible to detect the process translation if
// rosetta is used.
res, err := unix.SysctlRaw(translatedProcDarwin)
if err != nil || len(res) > 4 {
return host.Info().Architecture + "_err"
}
if res[0] == 1 {
return host.Info().Architecture + "_rosetta"
}
return host.Info().Architecture
}

View File

@ -0,0 +1,31 @@
// Copyright (c) 2022 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/>.
//go:build !darwin
// +build !darwin
package sentry
import "github.com/elastic/go-sysinfo"
func getHostAarch() string {
host, err := sysinfo.Host()
if err != nil {
return "not-detected"
}
return host.Info().Architecture
}

View File

@ -20,18 +20,20 @@ package sentry
import (
"errors"
"fmt"
"log"
"os"
"runtime"
"time"
"github.com/ProtonMail/proton-bridge/internal/constants"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/getsentry/sentry-go"
"github.com/sirupsen/logrus"
)
var skippedFunctions = []string{} //nolint[gochecknoglobals]
func init() { // nolint[noinit]
func init() { //nolint[noinit, gochecknoinits]
if err := sentry.Init(sentry.ClientOptions{
Dsn: constants.DSNSentry,
Release: constants.Revision,
@ -42,13 +44,20 @@ func init() { // nolint[noinit]
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetFingerprint([]string{"{{ default }}"})
scope.SetTag("UserID", "not-defined")
})
sentry.Logger = log.New(
logrus.WithField("pkg", "sentry-go").WriterLevel(logrus.WarnLevel),
"", 0,
)
}
type Reporter struct {
appName string
appVersion string
userAgent fmt.Stringer
hostArch string
}
// NewReporter creates new sentry reporter with appName and appVersion to report.
@ -57,6 +66,7 @@ func NewReporter(appName, appVersion string, userAgent fmt.Stringer) *Reporter {
appName: appName,
appVersion: appVersion,
userAgent: userAgent,
hostArch: getHostAarch(),
}
}
@ -109,7 +119,7 @@ func (r *Reporter) scopedReport(context map[string]interface{}, doReport func())
"Client": r.appName,
"Version": r.appVersion,
"UserAgent": r.userAgent.String(),
"UserID": "",
"HostArch": r.hostArch,
}
sentry.WithScope(func(scope *sentry.Scope) {
@ -179,3 +189,6 @@ func isFunctionFilteredOut(function string) bool {
func Flush(maxWaiTime time.Duration) {
sentry.Flush(maxWaiTime)
}
func (r *Reporter) SetClientFromManager(cm pmapi.Manager) {
}

View File

@ -23,6 +23,7 @@ import (
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/internal/store/cache"
"github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
bolt "go.etcd.io/bbolt"
)
@ -126,6 +127,7 @@ func (store *Store) getCachedMessage(messageID string) ([]byte, error) {
literal, err := job.GetResult()
if err != nil {
store.checkAndRemoveDeletedMessage(err, messageID)
return nil, err
}
@ -184,8 +186,21 @@ func (store *Store) BuildAndCacheMessage(ctx context.Context, messageID string)
literal, err := job.GetResult()
if err != nil {
store.checkAndRemoveDeletedMessage(err, messageID)
return err
}
return store.cache.Set(store.user.ID(), messageID, literal)
}
func (store *Store) checkAndRemoveDeletedMessage(err error, msgID string) {
if !pmapi.IsUnprocessableEntity(err) {
return
}
l := store.log.WithError(err).WithField("msgID", msgID)
l.Warn("Deleting message which was not found on API")
if deleteErr := store.deleteMessageEvent(msgID); deleteErr != nil {
l.WithField("deleteErr", deleteErr).Error("Failed to delete non-existed API message from DB")
}
}

View File

@ -63,10 +63,16 @@ func NewOnDiskCache(path string, cmp Compressor, opts Options) (Cache, error) {
}
file, err := ioutil.TempFile(path, "tmp")
defer func() {
file.Close() //nolint[errcheck]
os.Remove(file.Name()) //nolint[errcheck]
}()
if err != nil {
return nil, fmt.Errorf("cannot open test write target: %w", err)
}
if _, err := file.Write([]byte("test-write")); err != nil {
return nil, fmt.Errorf("cannot write to target: %w", err)
}
os.Remove(file.Name()) //nolint[errcheck]
usage := du.NewDiskUsage(path)

View File

@ -127,6 +127,6 @@ func TestCooldownNotSooner(t *testing.T) {
assert.True(t, testCooldown.isTooSoon())
// After given wait time it shouldn't be soon anymore.
time.Sleep(waitTime / 2)
time.Sleep(waitTime/2 + time.Millisecond)
assert.False(t, testCooldown.isTooSoon())
}

View File

@ -243,7 +243,7 @@ func (loop *eventLoop) processNextEvent() (more bool, err error) { // nolint[fun
}
// All errors except ErrUnauthorized (which is not possible to recover from) are ignored.
if err != nil && errors.Cause(err) != pmapi.ErrUnauthorized {
if err != nil && !pmapi.IsFailedAuth(errors.Cause(err)) && errors.Cause(err) != pmapi.ErrUnauthorized {
l.WithError(err).WithField("errors", loop.errCounter).Error("Error skipped")
loop.errCounter++
if loop.errCounter == errMaxSentry {
@ -477,7 +477,7 @@ func (loop *eventLoop) processMessages(eventLog *logrus.Entry, messages []*pmapi
msgLog.WithError(err).Warning("Message was not present in DB. Trying fetch...")
if msg, err = loop.client().GetMessage(context.Background(), message.ID); err != nil {
if _, ok := err.(pmapi.ErrUnprocessableEntity); ok {
if pmapi.IsUnprocessableEntity(err) {
msgLog.WithError(err).Warn("Skipping message update because message exists neither in local DB nor on API")
err = nil
continue

View File

@ -229,7 +229,10 @@ func (u *Users) MigrateCache(srcPath, dstPath string) error {
// (read-only is conserved). Do copy instead.
tmp, err := ioutil.TempFile(srcPath, "tmp")
if err == nil {
defer os.Remove(tmp.Name()) //nolint[errcheck]
defer func() {
tmp.Close() //nolint[errcheck]
os.Remove(tmp.Name()) //nolint[errcheck]
}()
if err := os.Rename(srcPath, dstPath); err == nil {
return nil

View File

@ -69,6 +69,7 @@ func newUser(
creds, err := credStorer.Get(userID)
if err != nil {
notifyKeychainRepair(eventListener, err)
return nil, nil, errors.Wrap(err, "failed to load user credentials")
}
@ -162,6 +163,7 @@ func (u *User) handleAuthRefresh(auth *pmapi.AuthRefresh) {
creds, err := u.credStorer.UpdateToken(u.userID, auth.UID, auth.RefreshToken)
if err != nil {
notifyKeychainRepair(u.listener, err)
u.log.WithError(err).Error("Failed to update refresh token in credentials store")
return
}
@ -223,7 +225,7 @@ func (u *User) UpdateSpace(apiUser *pmapi.User) {
// values from client.CurrentUser()
if apiUser == nil {
var err error
apiUser, err = u.client.GetUser(pmapi.ContextWithoutRetry(context.Background()))
apiUser, err = u.GetClient().GetUser(pmapi.ContextWithoutRetry(context.Background()))
if err != nil {
u.log.WithError(err).Warning("Cannot update user space")
return
@ -280,16 +282,21 @@ func (u *User) unlockIfNecessary() error {
return nil
}
switch errors.Cause(err) {
case pmapi.ErrNoConnection, pmapi.ErrUpgradeApplication:
u.log.WithError(err).Warn("Could not unlock user")
return nil
if pmapi.IsFailedAuth(err) || pmapi.IsFailedUnlock(err) {
if logoutErr := u.logout(); logoutErr != nil {
u.log.WithError(logoutErr).Warn("Could not logout user")
}
return errors.Wrap(err, "failed to unlock user")
}
if logoutErr := u.logout(); logoutErr != nil {
u.log.WithError(logoutErr).Warn("Could not logout user")
switch errors.Cause(err) {
case pmapi.ErrNoConnection, pmapi.ErrUpgradeApplication:
u.log.WithError(err).Warn("Skipping unlock for known reason")
default:
u.log.WithError(err).Error("Unknown unlock issue")
}
return errors.Wrap(err, "failed to unlock user")
return nil
}
// IsCombinedAddressMode returns whether user is set in combined or split mode.
@ -345,6 +352,10 @@ func (u *User) GetAddressID(address string) (id string, err error) {
return u.store.GetAddressID(address)
}
if u.client == nil {
return "", errors.New("bridge account is not fully connected to server")
}
addresses := u.client.Addresses()
pmapiAddress := addresses.ByEmail(address)
if pmapiAddress != nil {
@ -399,6 +410,7 @@ func (u *User) UpdateUser(ctx context.Context) error {
creds, err := u.credStorer.UpdateEmails(u.userID, u.client.Addresses().ActiveEmails())
if err != nil {
notifyKeychainRepair(u.listener, err)
return err
}
@ -436,6 +448,7 @@ func (u *User) SwitchAddressMode() error {
creds, err := u.credStorer.SwitchAddressMode(u.userID)
if err != nil {
notifyKeychainRepair(u.listener, err)
return errors.Wrap(err, "could not switch credentials store address mode")
}
@ -473,15 +486,19 @@ func (u *User) Logout() error {
return nil
}
if err := u.client.AuthDelete(context.Background()); err != nil {
if u.client == nil {
u.log.Warn("Failed to delete auth: no client")
} else if err := u.client.AuthDelete(context.Background()); err != nil {
u.log.WithError(err).Warn("Failed to delete auth")
}
creds, err := u.credStorer.Logout(u.userID)
if err != nil {
notifyKeychainRepair(u.listener, err)
u.log.WithError(err).Warn("Could not log user out from credentials store")
if err := u.credStorer.Delete(u.userID); err != nil {
notifyKeychainRepair(u.listener, err)
u.log.WithError(err).Error("Could not delete user from credentials store")
}
} else {

View File

@ -170,7 +170,7 @@ func TestCheckBridgeLoginLoggedOut(t *testing.T) {
// Mock init of user.
m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil),
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, errors.New("ErrUnauthorized")),
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, pmapi.ErrUnauthorized),
m.pmapiClient.EXPECT().Addresses().Return(nil),
// Mock CheckBridgeLogin.

View File

@ -23,6 +23,7 @@ import (
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
gomock "github.com/golang/mock/gomock"
r "github.com/stretchr/testify/require"
)
@ -46,7 +47,7 @@ func TestNewUserUnlockFails(t *testing.T) {
m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil),
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
m.pmapiClient.EXPECT().IsUnlocked().Return(false),
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(errors.New("bad password")),
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(pmapi.ErrUnlockFailed{OriginalError: errors.New("bad password")}),
// Handle of unlock error.
m.pmapiClient.EXPECT().AuthDelete(gomock.Any()).Return(nil),

View File

@ -27,6 +27,7 @@ import (
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/internal/metrics"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/ProtonMail/proton-bridge/pkg/keychain"
"github.com/ProtonMail/proton-bridge/pkg/listener"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/hashicorp/go-multierror"
@ -130,6 +131,7 @@ func (u *Users) loadUsersFromCredentialsStore() error {
userIDs, err := u.credStorer.List()
if err != nil {
notifyKeychainRepair(u.events, err)
return err
}
@ -178,14 +180,17 @@ func (u *Users) loadConnectedUser(ctx context.Context, user *User, creds *creden
return connectErr
}
if logoutErr := user.logout(); logoutErr != nil {
logrus.WithError(logoutErr).Warn("Could not logout user")
if pmapi.IsFailedAuth(connectErr) {
if logoutErr := user.logout(); logoutErr != nil {
logrus.WithError(logoutErr).Warn("Could not logout user")
}
}
return errors.Wrap(err, "could not refresh token")
}
// Update the user's credentials with the latest auth used to connect this user.
if creds, err = u.credStorer.UpdateToken(creds.UserID, auth.UID, auth.RefreshToken); err != nil {
notifyKeychainRepair(u.events, err)
return errors.Wrap(err, "could not create get user's refresh token")
}
@ -224,12 +229,14 @@ func (u *Users) FinishLogin(client pmapi.Client, auth *pmapi.Auth, password []by
// Update the user's credentials with the latest auth used to connect this user.
if _, err := u.credStorer.UpdateToken(auth.UserID, auth.UID, auth.RefreshToken); err != nil {
notifyKeychainRepair(u.events, err)
return nil, errors.Wrap(err, "failed to load user credentials")
}
// Update the password in case the user changed it.
creds, err := u.credStorer.UpdatePassword(apiUser.ID, passphrase)
if err != nil {
notifyKeychainRepair(u.events, err)
return nil, errors.Wrap(err, "failed to update password of user in credentials store")
}
@ -258,6 +265,7 @@ func (u *Users) addNewUser(client pmapi.Client, apiUser *pmapi.User, auth *pmapi
defer u.lock.Unlock()
if _, err := u.credStorer.Add(apiUser.ID, apiUser.Name, auth.UID, auth.RefreshToken, passphrase, client.Addresses().ActiveEmails()); err != nil {
notifyKeychainRepair(u.events, err)
return errors.Wrap(err, "failed to add user credentials to credentials store")
}
@ -382,6 +390,7 @@ func (u *Users) DeleteUser(userID string, clearStore bool) error {
}
if err := u.credStorer.Delete(userID); err != nil {
notifyKeychainRepair(u.events, err)
log.WithError(err).Error("Cannot remove user")
return err
}
@ -441,3 +450,9 @@ func (u *Users) crashBandicoot(username string) {
panic("Your wish is my command… I crash!")
}
}
func notifyKeychainRepair(l listener.Listener, err error) {
if err == keychain.ErrMacKeychainRebuild {
l.Emit(events.CredentialsErrorEvent, err.Error())
}
}

View File

@ -24,6 +24,7 @@ import (
"github.com/ProtonMail/proton-bridge/internal/events"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
gomock "github.com/golang/mock/gomock"
r "github.com/stretchr/testify/require"
)
@ -80,11 +81,11 @@ func TestNewUsersWithConnectedUserWithBadToken(t *testing.T) {
m := initMocks(t)
defer m.ctrl.Finish()
m.clientManager.EXPECT().NewClientWithRefresh(gomock.Any(), "uid", "acc").Return(nil, nil, errors.New("bad token"))
m.clientManager.EXPECT().NewClientWithRefresh(gomock.Any(), "uid", "acc").Return(nil, nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad token")})
m.clientManager.EXPECT().NewClient("uid", "", "acc", time.Time{}).Return(m.pmapiClient)
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any())
m.pmapiClient.EXPECT().IsUnlocked().Return(false)
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(errors.New("not authorized"))
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(pmapi.ErrAuthFailed{OriginalError: errors.New("not authorized")})
m.pmapiClient.EXPECT().AuthDelete(gomock.Any())
m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil)
@ -93,7 +94,6 @@ func TestNewUsersWithConnectedUserWithBadToken(t *testing.T) {
m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user")
m.eventListener.EXPECT().Emit(events.LogoutEvent, "user")
m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user")
m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me")
checkUsersNew(t, m, []*credentials.Credentials{testCredentialsDisconnected})

View File

@ -37,7 +37,6 @@ import (
pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks"
tests "github.com/ProtonMail/proton-bridge/test"
gomock "github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
r "github.com/stretchr/testify/require"
)
@ -175,6 +174,7 @@ func initMocks(t *testing.T) mocks {
cacheFile, err := ioutil.TempFile("", "bridge-store-cache-*.db")
r.NoError(t, err, "could not get temporary file for store cache")
r.NoError(t, cacheFile.Close())
m := mocks{
t: t,
@ -201,6 +201,7 @@ func initMocks(t *testing.T) mocks {
dbFile, err := ioutil.TempFile(t.TempDir(), "bridge-store-db-*.db")
r.NoError(t, err, "could not get temporary file for store db")
r.NoError(t, dbFile.Close())
return store.New(
sentryReporter,
@ -329,7 +330,7 @@ func mockInitDisconnectedUser(m mocks) {
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
// Mock of store initialisation for the unauthorized user.
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, errors.New("ErrUnauthorized")),
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, pmapi.ErrUnauthorized),
m.pmapiClient.EXPECT().Addresses().Return(nil),
)
}

View File

@ -32,7 +32,7 @@ import (
// RemoveOldVersions is a noop on darwin; we don't test it there.
func TestRemoveOldVersions(t *testing.T) {
updates, err := ioutil.TempDir("", "updates")
updates, err := ioutil.TempDir(t.TempDir(), "updates")
require.NoError(t, err)
v := newTestVersioner(t, "myCoolApp", updates, "2.3.4-beta", "2.3.4", "2.3.5", "2.4.0")

View File

@ -65,11 +65,13 @@ func makeDummyVersionDirectory(t *testing.T, exeName, updates, version string) s
exe, err := os.Create(filepath.Join(target, getExeName(exeName)))
require.NoError(t, err)
require.NotNil(t, exe)
require.NoError(t, exe.Close())
require.NoError(t, os.Chmod(exe.Name(), 0700))
sig, err := os.Create(filepath.Join(target, getExeName(exeName)+".sig"))
require.NoError(t, err)
require.NotNil(t, sig)
require.NoError(t, sig.Close())
return target
}

View File

@ -40,6 +40,16 @@ func init() { // nolint[noinit]
defaultHelper = MacOSKeychain
}
func parseError(original error) error {
if original == nil {
return nil
}
if strings.Contains(original.Error(), "25293") {
return ErrMacKeychainRebuild
}
return original
}
func newMacOSHelper(url string) (credentials.Helper, error) {
return &macOSHelper{url: url}, nil
}
@ -76,7 +86,7 @@ func (h *macOSHelper) Add(creds *credentials.Credentials) error {
query := newQuery(hostURL, userID)
query.SetData([]byte(creds.Secret))
return keychain.AddItem(query)
return parseError(keychain.AddItem(query))
}
func (h *macOSHelper) Delete(secretURL string) error {
@ -87,7 +97,7 @@ func (h *macOSHelper) Delete(secretURL string) error {
query := newQuery(hostURL, userID)
return keychain.DeleteItem(query)
return parseError(keychain.DeleteItem(query))
}
func (h *macOSHelper) Get(secretURL string) (string, string, error) {
@ -102,7 +112,7 @@ func (h *macOSHelper) Get(secretURL string) (string, string, error) {
results, err := keychain.QueryItem(query)
if err != nil {
return "", "", err
return "", "", parseError(err)
}
if len(results) == 0 {
@ -121,7 +131,7 @@ func (h *macOSHelper) List() (map[string]string, error) {
userIDs, err := keychain.GetGenericPasswordAccounts(h.url)
if err != nil {
return nil, err
return nil, parseError(err)
}
for _, userID := range userIDs {

View File

@ -0,0 +1,220 @@
// Copyright (c) 2022 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"
"github.com/godbus/dbus"
"github.com/keybase/go-keychain/secretservice"
)
const (
serverAtt = "server"
labelAtt = "label"
usernameAtt = "username"
defaulDomain = "protonmail/bridge/users/"
defaultLabel = "Docker Credentials"
)
func getSession() (*secretservice.SecretService, *secretservice.Session, error) {
service, err := secretservice.NewService()
if err != nil {
return nil, nil, err
}
session, err := service.OpenSession(secretservice.AuthenticationDHAES)
if err != nil {
return nil, nil, err
}
return service, session, nil
}
func handleTimeout(f func() error) error {
err := f()
if err == secretservice.ErrPromptTimedOut {
return f()
}
return err
}
func getItems(service *secretservice.SecretService, attributes map[string]string) ([]dbus.ObjectPath, error) {
if err := unlock(service); err != nil {
return nil, err
}
var items []dbus.ObjectPath
err := handleTimeout(func() error {
var err error
items, err = service.SearchCollection(
secretservice.DefaultCollection,
attributes,
)
return err
})
if err != nil {
return nil, err
}
return items, err
}
func unlock(service *secretservice.SecretService) error {
return handleTimeout(func() error {
return service.Unlock([]dbus.ObjectPath{secretservice.DefaultCollection})
})
}
// SecretServiceDBusHelper is wrapper around keybase/go-keychain/secretservice
// library.
type SecretServiceDBusHelper struct{}
// Add appends credentials to the store.
func (s *SecretServiceDBusHelper) Add(creds *credentials.Credentials) error {
service, session, err := getSession()
if err != nil {
return err
}
defer service.CloseSession(session)
if err := unlock(service); err != nil {
return err
}
secret, err := session.NewSecret([]byte(creds.Secret))
if err != nil {
return err
}
attributes := map[string]string{
usernameAtt: creds.Username,
serverAtt: creds.ServerURL,
labelAtt: defaultLabel,
"xdg:schema": "io.docker.Credentials",
"docker_cli": "1",
}
return handleTimeout(func() error {
_, err = service.CreateItem(
secretservice.DefaultCollection,
secretservice.NewSecretProperties(creds.ServerURL, attributes),
secret,
secretservice.ReplaceBehaviorReplace,
)
return err
})
}
// Delete removes credentials from the store.
func (s *SecretServiceDBusHelper) Delete(serverURL string) error {
service, session, err := getSession()
if err != nil {
return err
}
defer service.CloseSession(session)
items, err := getItems(service, map[string]string{
labelAtt: defaultLabel,
serverAtt: serverURL,
})
if len(items) == 0 || err != nil {
return err
}
return handleTimeout(func() error {
return service.DeleteItem(items[0])
})
}
// Get retrieves credentials from the store.
// It returns username and secret as strings.
func (s *SecretServiceDBusHelper) Get(serverURL string) (string, string, error) {
service, session, err := getSession()
if err != nil {
return "", "", err
}
defer service.CloseSession(session)
if err := unlock(service); err != nil {
return "", "", err
}
items, err := getItems(service, map[string]string{
labelAtt: defaultLabel,
serverAtt: serverURL,
})
if len(items) == 0 || err != nil {
return "", "", err
}
item := items[0]
attributes, err := service.GetAttributes(item)
if err != nil {
return "", "", err
}
var secretPlaintext []byte
err = handleTimeout(func() error {
var err error
secretPlaintext, err = service.GetSecret(item, *session)
return err
})
if err != nil {
return "", "", err
}
return attributes[usernameAtt], string(secretPlaintext), nil
}
// List returns the stored serverURLs and their associated usernames.
func (s *SecretServiceDBusHelper) List() (map[string]string, error) {
userIDByURL := make(map[string]string)
service, session, err := getSession()
if err != nil {
return nil, err
}
defer service.CloseSession(session)
items, err := getItems(service, map[string]string{labelAtt: defaultLabel})
if err != nil {
return nil, err
}
for _, it := range items {
attributes, err := service.GetAttributes(it)
if err != nil {
return nil, err
}
if !strings.HasPrefix(attributes[serverAtt], defaulDomain) {
continue
}
userIDByURL[attributes[serverAtt]] = attributes[usernameAtt]
}
return userIDByURL, nil
}

View File

@ -28,35 +28,46 @@ import (
)
const (
Pass = "pass-app"
GnomeKeyring = "gnome-keyring"
Pass = "pass-app"
SecretService = "secret-service"
SecretServiceDBus = "secret-service-dbus"
)
func init() { // nolint[noinit]
Helpers = make(map[string]helperConstructor)
if _, err := exec.LookPath("pass"); err == nil {
if isUsable(newDBusHelper("")) {
Helpers[SecretServiceDBus] = newDBusHelper
}
if _, err := exec.LookPath("gnome-keyring"); err == nil && isUsable(newSecretServiceHelper("")) {
Helpers[SecretService] = newSecretServiceHelper
}
if _, err := exec.LookPath("pass"); err == nil && isUsable(newPassHelper("")) {
Helpers[Pass] = newPassHelper
}
if _, err := exec.LookPath("gnome-keyring"); err == nil {
Helpers[GnomeKeyring] = newGnomeKeyringHelper
}
defaultHelper = SecretServiceDBus
// If Pass is available, use it by default.
// Otherwise, if GnomeKeyring is available, use it by default.
if _, ok := Helpers[Pass]; ok && isUsable(newPassHelper("")) {
// Otherwise, if SecretService is available, use it by default.
if _, ok := Helpers[Pass]; ok {
defaultHelper = Pass
} else if _, ok := Helpers[GnomeKeyring]; ok && isUsable(newGnomeKeyringHelper("")) {
defaultHelper = GnomeKeyring
} else if _, ok := Helpers[SecretService]; ok {
defaultHelper = SecretService
}
}
func newDBusHelper(string) (credentials.Helper, error) {
return &SecretServiceDBusHelper{}, nil
}
func newPassHelper(string) (credentials.Helper, error) {
return &pass.Pass{}, nil
}
func newGnomeKeyringHelper(string) (credentials.Helper, error) {
func newSecretServiceHelper(string) (credentials.Helper, error) {
return &secretservice.Secretservice{}, nil
}

View File

@ -37,6 +37,9 @@ var (
// ErrNoKeychain indicates that no suitable keychain implementation could be loaded.
ErrNoKeychain = errors.New("no keychain") // nolint[noglobals]
// ErrMacKeychainRebuild is returned on macOS with blocked or corrupted keychain.
ErrMacKeychainRebuild = errors.New("keychain error -25293")
// Helpers holds all discovered keychain helpers. It is populated in init().
Helpers map[string]helperConstructor // nolint[noglobals]

View File

@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"math"
"runtime"
"testing"
"time"
@ -33,6 +34,7 @@ var (
wantOutput = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
testProcessSleep = 100 // ms
runParallelTimeOverhead = 150 // ms
windowsCIExtra = 500 // ms - estimated experimentally
)
func TestParallel(t *testing.T) {
@ -56,6 +58,9 @@ func TestParallel(t *testing.T) {
wantMinDuration := int(math.Ceil(float64(len(testInput))/float64(workers))) * testProcessSleep
wantMaxDuration := wantMinDuration + runParallelTimeOverhead
if runtime.GOOS == "windows" {
wantMaxDuration += windowsCIExtra
}
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)
})

View File

@ -19,11 +19,11 @@ package pmapi
import (
"context"
"errors"
"strings"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/go-resty/resty/v2"
"github.com/pkg/errors"
)
// Address statuses.
@ -201,7 +201,7 @@ func (c *client) unlockAddress(passphrase []byte, address *Address) error {
kr, err := address.Keys.UnlockAll(passphrase, c.userKeyRing)
if err != nil {
return err
return errors.Wrap(err, "cannot unlock address keys for "+address.ID)
}
c.addrKeyRing[address.ID] = kr

View File

@ -51,7 +51,7 @@ type TwoFAInfo struct {
}
func (twoFAInfo TwoFAInfo) hasTwoFactor() bool {
return twoFAInfo.Enabled > 0
return twoFAInfo.Enabled > TwoFADisabled
}
type TwoFAStatus int
@ -185,7 +185,7 @@ func (c *client) authRefresh(ctx context.Context) error {
auth, err := c.manager.authRefresh(ctx, c.uid, c.ref)
if err != nil {
if err != ErrNoConnection {
if IsFailedAuth(err) {
c.sendAuthRefresh(nil)
}
return err

View File

@ -0,0 +1,122 @@
// Copyright (c) 2022 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"
"net/http"
"strings"
"github.com/stretchr/testify/require"
)
type testRefreshResponse struct {
Code int
AccessToken string
ExpiresIn int
TokenType string
Scope string
Scopes []string
UID string
RefreshToken string
LocalID int
r *require.Assertions
}
var tokenID = 0
func newTestRefreshToken(r *require.Assertions) testRefreshResponse {
tokenID++
scopes := []string{
"full",
"self",
"parent",
"user",
"loggedin",
"paid",
"nondelinquent",
"mail",
"verified",
}
return testRefreshResponse{
Code: 1000,
AccessToken: fmt.Sprintf("acc%d", tokenID),
ExpiresIn: 3600,
TokenType: "Bearer",
Scope: strings.Join(scopes, " "),
Scopes: scopes,
UID: fmt.Sprintf("uid%d", tokenID),
RefreshToken: fmt.Sprintf("ref%d", tokenID),
r: r,
}
}
func (r *testRefreshResponse) isCorrectRefreshToken(body io.ReadCloser) int {
request := authRefreshReq{}
err := json.NewDecoder(body).Decode(&request)
r.r.NoError(body.Close())
r.r.NoError(err)
if r.UID != request.UID {
return http.StatusUnprocessableEntity
}
if r.RefreshToken != request.RefreshToken {
return http.StatusBadRequest
}
return http.StatusOK
}
func (r *testRefreshResponse) handleAuthRefresh(response http.ResponseWriter, request *http.Request) {
if code := r.isCorrectRefreshToken(request.Body); code != http.StatusOK {
response.WriteHeader(code)
return
}
tokenID++
r.AccessToken = fmt.Sprintf("acc%d", tokenID)
r.RefreshToken = fmt.Sprintf("ref%d", tokenID)
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusOK)
r.r.NoError(json.NewEncoder(response).Encode(r))
}
func (r *testRefreshResponse) wantAuthRefresh() AuthRefresh {
return AuthRefresh{
UID: r.UID,
AccessToken: r.AccessToken,
RefreshToken: r.RefreshToken,
ExpiresIn: int64(r.ExpiresIn),
Scopes: r.Scopes,
}
}
func (r *testRefreshResponse) isAuthorized(header http.Header) bool {
return header.Get("x-pm-uid") == r.UID && header.Get("Authorization") == "Bearer "+r.AccessToken
}
func (r *testRefreshResponse) handleAuthCheckOnly(response http.ResponseWriter, request *http.Request) {
if r.isAuthorized(request.Header) {
response.WriteHeader(http.StatusOK)
} else {
response.WriteHeader(http.StatusUnauthorized)
}
}

View File

@ -25,179 +25,210 @@ import (
"testing"
"time"
a "github.com/stretchr/testify/assert"
r "github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
func TestAutomaticAuthRefresh(t *testing.T) {
var wantAuthRefresh = &AuthRefresh{
UID: "testUID",
AccessToken: "testAcc",
RefreshToken: "testRef",
ExpiresIn: 100,
}
r := require.New(t)
mux := http.NewServeMux()
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
currentTokens := newTestRefreshToken(r)
testUID := currentTokens.UID
testAcc := currentTokens.AccessToken
testRef := currentTokens.RefreshToken
currentTokens.ExpiresIn = 100
if err := json.NewEncoder(w).Encode(wantAuthRefresh); err != nil {
panic(err)
}
})
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
ts := httptest.NewServer(mux)
var gotAuthRefresh *AuthRefresh
c := New(Config{HostURL: ts.URL}).
NewClient("uid", "acc", "ref", time.Now().Add(-time.Second))
NewClient(testUID, testAcc, testRef, time.Now().Add(-time.Second))
c.AddAuthRefreshHandler(func(auth *AuthRefresh) { gotAuthRefresh = auth })
// Make a request with an access token that already expired one second ago.
_, err := c.GetAddresses(context.Background())
r.NoError(t, err)
r.NoError(err)
wantAuthRefresh := currentTokens.wantAuthRefresh()
// The auth callback should have been called.
a.Equal(t, *wantAuthRefresh, *gotAuthRefresh)
r.NotNil(gotAuthRefresh)
r.Equal(wantAuthRefresh, *gotAuthRefresh)
cl := c.(*client) //nolint[forcetypeassert] we want to panic here
a.Equal(t, wantAuthRefresh.AccessToken, cl.acc)
a.Equal(t, wantAuthRefresh.RefreshToken, cl.ref)
a.WithinDuration(t, expiresIn(100), cl.exp, time.Second)
r.Equal(wantAuthRefresh.AccessToken, cl.acc)
r.Equal(wantAuthRefresh.RefreshToken, cl.ref)
r.WithinDuration(expiresIn(100), cl.exp, time.Second)
}
func Test401AuthRefresh(t *testing.T) {
var wantAuthRefresh = &AuthRefresh{
UID: "testUID",
AccessToken: "testAcc",
RefreshToken: "testRef",
}
r := require.New(t)
currentTokens := newTestRefreshToken(r)
testUID := currentTokens.UID
testRef := currentTokens.RefreshToken
mux := http.NewServeMux()
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(wantAuthRefresh); err != nil {
panic(err)
}
})
var call int
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
call++
if call == 1 {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
})
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
ts := httptest.NewServer(mux)
var gotAuthRefresh *AuthRefresh
// Create a new client.
c := New(Config{HostURL: ts.URL}).
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
m := New(Config{HostURL: ts.URL})
c := m.NewClient(testUID, "oldAccToken", testRef, time.Now().Add(time.Hour))
// Register an auth handler.
c.AddAuthRefreshHandler(func(auth *AuthRefresh) { gotAuthRefresh = auth })
// The first request will fail with 401, triggering a refresh and retry.
_, err := c.GetAddresses(context.Background())
r.NoError(t, err)
r.NoError(err)
// The auth callback should have been called.
r.Equal(t, *wantAuthRefresh, *gotAuthRefresh)
r.NotNil(gotAuthRefresh)
r.Equal(currentTokens.wantAuthRefresh(), *gotAuthRefresh)
}
func Test401RevokedAuth(t *testing.T) {
r := require.New(t)
currentTokens := newTestRefreshToken(r)
mux := http.NewServeMux()
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
ts := httptest.NewServer(mux)
c := New(Config{HostURL: ts.URL}).
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
NewClient("badUID", "badAcc", "badRef", time.Now().Add(time.Hour))
// The request will fail with 401, triggering a refresh.
// The retry will also fail with 401, returning an error.
_, err := c.GetAddresses(context.Background())
r.EqualError(t, err, ErrUnauthorized.Error())
r.True(IsFailedAuth(err))
}
func Test401RevokedAuthTokenUpdate(t *testing.T) {
var oldAuth = &AuthRefresh{
UID: "UID",
AccessToken: "oldAcc",
RefreshToken: "oldRef",
ExpiresIn: 3600,
}
var newAuth = &AuthRefresh{
UID: "UID",
AccessToken: "newAcc",
RefreshToken: "newRef",
}
func Test401OldRefreshToken(t *testing.T) {
r := require.New(t)
currentTokens := newTestRefreshToken(r)
mux := http.NewServeMux()
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
ts := httptest.NewServer(mux)
c := New(Config{HostURL: ts.URL}).
NewClient(currentTokens.UID, "oldAcc", "oldRef", time.Now().Add(time.Hour))
// The request will fail with 401, triggering a refresh.
// The retry will also fail with 401, returning an error.
_, err := c.GetAddresses(context.Background())
r.True(IsFailedAuth(err))
}
func Test401NoAccessToken(t *testing.T) {
r := require.New(t)
currentTokens := newTestRefreshToken(r)
testUID := currentTokens.UID
testRef := currentTokens.RefreshToken
mux := http.NewServeMux()
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
ts := httptest.NewServer(mux)
c := New(Config{HostURL: ts.URL}).
NewClient(testUID, "", testRef, time.Now().Add(time.Hour))
// The request will fail with 401, triggering a refresh. After the refresh it should succeed.
_, err := c.GetAddresses(context.Background())
r.NoError(err)
}
func Test401ExpiredAuthUpdateUser(t *testing.T) {
r := require.New(t)
mux := http.NewServeMux()
currentTokens := newTestRefreshToken(r)
testUID := currentTokens.UID
testRef := currentTokens.RefreshToken
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
if !currentTokens.isAuthorized(r.Header) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(newAuth); err != nil {
w.WriteHeader(http.StatusOK)
respObj := struct {
Code int
User *User
}{
Code: 1000,
User: &User{
ID: "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==",
Name: "jason",
UsedSpace: &usedSpace,
},
}
if err := json.NewEncoder(w).Encode(respObj); err != nil {
panic(err)
}
})
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == ("Bearer " + oldAuth.AccessToken) {
if !currentTokens.isAuthorized(r.Header) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.Header.Get("Authorization") == ("Bearer " + newAuth.AccessToken) {
w.WriteHeader(http.StatusOK)
return
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
respObj := struct {
Code int
Addresses []*Address
}{
Code: 1000,
Addresses: []*Address{},
}
if err := json.NewEncoder(w).Encode(respObj); err != nil {
panic(err)
}
})
ts := httptest.NewServer(mux)
c := New(Config{HostURL: ts.URL}).
NewClient(oldAuth.UID, oldAuth.AccessToken, oldAuth.RefreshToken, time.Now().Add(time.Hour))
m := New(Config{HostURL: ts.URL})
c, _, err := m.NewClientWithRefresh(context.Background(), testUID, testRef)
r.NoError(err)
// The request will fail with 401, triggering a refresh. After the refresh it should succeed.
_, err := c.GetAddresses(context.Background())
r.NoError(t, err)
_, err = c.UpdateUser(context.Background())
r.NoError(err)
}
func TestAuth2FA(t *testing.T) {
r := require.New(t)
twoFACode := "code"
finish, c := newTestClientCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
var twoFAreq auth2FAReq
r.NoError(t, json.NewDecoder(req.Body).Decode(&twoFAreq))
r.Equal(t, twoFAreq.TwoFactorCode, twoFACode)
r.NoError(json.NewDecoder(req.Body).Decode(&twoFAreq))
r.Equal(twoFAreq.TwoFactorCode, twoFACode)
return "/auth/2fa/post_response.json"
},
@ -205,31 +236,33 @@ func TestAuth2FA(t *testing.T) {
defer finish()
err := c.Auth2FA(context.Background(), twoFACode)
r.NoError(t, err)
r.NoError(err)
}
func TestAuth2FA_Fail(t *testing.T) {
r := require.New(t)
finish, c := newTestClientCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
return "/auth/2fa/post_401_bad_password.json"
},
)
defer finish()
err := c.Auth2FA(context.Background(), "code")
r.Equal(t, ErrBad2FACode, err)
r.Equal(ErrBad2FACode, err)
}
func TestAuth2FA_Retry(t *testing.T) {
r := require.New(t)
finish, c := newTestClientCallbacks(t,
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
return "/auth/2fa/post_422_bad_password.json"
},
)
defer finish()
err := c.Auth2FA(context.Background(), "code")
r.Equal(t, ErrBad2FACodeTryAgain, err)
r.Equal(ErrBad2FACodeTryAgain, err)
}

View File

@ -19,8 +19,6 @@ package pmapi
import (
"context"
"github.com/pkg/errors"
)
// Unlock unlocks all the user and address keys using the given passphrase, creating user and address keyrings.
@ -34,26 +32,26 @@ func (c *client) Unlock(ctx context.Context, passphrase []byte) (err error) {
// unlock unlocks the user's keys but without locking the keyring lock first.
// Should only be used internally by methods that first lock the lock.
func (c *client) unlock(ctx context.Context, passphrase []byte) (err error) {
if _, err = c.CurrentUser(ctx); err != nil {
return
func (c *client) unlock(ctx context.Context, passphrase []byte) error {
if _, err := c.CurrentUser(ctx); err != nil {
return err
}
if c.userKeyRing == nil {
if err = c.unlockUser(passphrase); err != nil {
return errors.Wrap(err, "failed to unlock user")
if err := c.unlockUser(passphrase); err != nil {
return ErrUnlockFailed{err}
}
}
for _, address := range c.addresses {
if c.addrKeyRing[address.ID] == nil {
if err = c.unlockAddress(passphrase, address); err != nil {
return errors.Wrap(err, "failed to unlock address")
if err := c.unlockAddress(passphrase, address); err != nil {
return ErrUnlockFailed{err}
}
}
}
return
return nil
}
func (c *client) ReloadKeys(ctx context.Context, passphrase []byte) (err error) {

View File

@ -82,4 +82,5 @@ type AuthRefreshHandler func(*AuthRefresh)
type clientManager interface {
r(context.Context) *resty.Request
authRefresh(context.Context, string, string) (*AuthRefresh, error)
setSentryUserID(userID string)
}

View File

@ -60,11 +60,16 @@ func formatAsAddress(rawURL string) string {
panic(err)
}
host := url.Host
if host == "" {
host = url.Path
}
port := "443"
if url.Scheme == "http" {
port = "80"
}
return net.JoinHostPort(url.Host, port)
return net.JoinHostPort(host, port)
}
// DialTLS dials the given network/address. If it fails, it retries using a proxy.

View File

@ -36,11 +36,16 @@ const (
proxyDoHTimeout = 20 * time.Second
proxyCanReachTimeout = 20 * time.Second
proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
Quad9Provider = "https://dns11.quad9.net/dns-query"
Quad9PortProvider = "https://dns11.quad9.net:5053/dns-query"
GoogleProvider = "https://dns.google/dns-query"
)
var dohProviders = []string{ //nolint[gochecknoglobals]
"https://dns11.quad9.net/dns-query",
"https://dns.google/dns-query",
Quad9Provider,
Quad9PortProvider,
GoogleProvider,
}
// proxyProvider manages known proxies.

View File

@ -27,12 +27,6 @@ import (
"golang.org/x/net/http/httpproxy"
)
const (
TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
TestQuad9Provider = "https://dns11.quad9.net/dns-query"
TestGoogleProvider = "https://dns.google/dns-query"
)
func TestProxyProvider_FindProxy(t *testing.T) {
proxy := getTrustedServer()
defer closeServer(proxy)
@ -142,17 +136,28 @@ func TestProxyProvider_FindProxy_CanReachTimeout(t *testing.T) {
}
func TestProxyProvider_DoHLookup_Quad9(t *testing.T) {
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestQuad9Provider)
records, err := p.dohLookup(context.Background(), proxyQuery, Quad9Provider)
r.NoError(t, err)
r.NotEmpty(t, records)
}
// DISABLEDTestProxyProvider_DoHLookup_Quad9Port cannot run on CI due to custom
// port filter. Basic functionality should be covered by other tests. Keeping
// code here to be able to run it locally if needed.
func DISABLEDTestProxyProviderDoHLookupQuad9Port(t *testing.T) {
p := newProxyProvider(Config{}, []string{Quad9PortProvider, GoogleProvider}, proxyQuery)
records, err := p.dohLookup(context.Background(), proxyQuery, Quad9PortProvider)
r.NoError(t, err)
r.NotEmpty(t, records)
}
func TestProxyProvider_DoHLookup_Google(t *testing.T) {
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestGoogleProvider)
records, err := p.dohLookup(context.Background(), proxyQuery, GoogleProvider)
r.NoError(t, err)
r.NotEmpty(t, records)
}
@ -160,7 +165,7 @@ func TestProxyProvider_DoHLookup_Google(t *testing.T) {
func TestProxyProvider_DoHLookup_FindProxy(t *testing.T) {
skipIfProxyIsSet(t)
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
url, err := p.findReachableServer()
r.NoError(t, err)
@ -170,7 +175,7 @@ func TestProxyProvider_DoHLookup_FindProxy(t *testing.T) {
func TestProxyProvider_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) {
skipIfProxyIsSet(t)
p := newProxyProvider(Config{}, []string{"https://unreachable", TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
p := newProxyProvider(Config{}, []string{"https://unreachable", Quad9Provider, GoogleProvider}, proxyQuery)
url, err := p.findReachableServer()
r.NoError(t, err)

View File

@ -251,3 +251,18 @@ func TestProxyDialer_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBloc
require.NoError(t, err)
require.Equal(t, formatAsAddress(proxy2.URL), d.proxyAddress)
}
func TestFormatAsAddress(t *testing.T) {
r := require.New(t)
testData := map[string]string{
"sub.domain.tld": "sub.domain.tld:443",
"http://sub.domain.tld": "sub.domain.tld:80",
"https://sub.domain.tld": "sub.domain.tld:443",
"ftp://sub.domain.tld": "sub.domain.tld:443",
"//sub.domain.tld": "sub.domain.tld:443",
}
for rawURL, wantURL := range testData {
r.Equal(wantURL, formatAsAddress(rawURL))
}
}

View File

@ -31,10 +31,58 @@ var (
ErrPasswordWrong = errors.New("wrong password")
)
// ErrUnprocessableEntity ...
type ErrUnprocessableEntity struct {
originalError error
OriginalError error
}
func IsUnprocessableEntity(err error) bool {
_, ok := err.(ErrUnprocessableEntity)
return ok
}
func (err ErrUnprocessableEntity) Error() string {
return err.originalError.Error()
return err.OriginalError.Error()
}
// ErrBadRequest ...
type ErrBadRequest struct {
OriginalError error
}
func IsBadRequest(err error) bool {
_, ok := err.(ErrBadRequest)
return ok
}
func (err ErrBadRequest) Error() string {
return err.OriginalError.Error()
}
// ErrAuthFailed ...
type ErrAuthFailed struct {
OriginalError error
}
func IsFailedAuth(err error) bool {
_, ok := err.(ErrAuthFailed)
return ok
}
func (err ErrAuthFailed) Error() string {
return err.OriginalError.Error()
}
// ErrUnlockFailed ...
type ErrUnlockFailed struct {
OriginalError error
}
func IsFailedUnlock(err error) bool {
_, ok := err.(ErrUnlockFailed)
return ok
}
func (err ErrUnlockFailed) Error() string {
return err.OriginalError.Error()
}

View File

@ -23,6 +23,7 @@ import (
"sync"
"time"
"github.com/getsentry/sentry-go"
"github.com/go-resty/resty/v2"
)
@ -32,11 +33,13 @@ type manager struct {
isDown bool
locker sync.Locker
refreshingAuth sync.Locker
connectionObservers []ConnectionObserver
proxyDialer *ProxyTLSDialer
pingMutex *sync.RWMutex
isPinging bool
pingMutex *sync.RWMutex
isPinging bool
setSentryUserIDOnce sync.Once
}
func New(cfg Config) Manager {
@ -45,11 +48,13 @@ func New(cfg Config) Manager {
func newManager(cfg Config) *manager {
m := &manager{
cfg: cfg,
rc: resty.New().EnableTrace(),
locker: &sync.Mutex{},
pingMutex: &sync.RWMutex{},
isPinging: false,
cfg: cfg,
rc: resty.New().EnableTrace(),
locker: &sync.Mutex{},
refreshingAuth: &sync.Mutex{},
pingMutex: &sync.RWMutex{},
isPinging: false,
setSentryUserIDOnce: sync.Once{},
}
proxyDialer, transport := newProxyDialerAndTransport(cfg)
@ -158,3 +163,11 @@ func (m *manager) handleRequestFailure(req *resty.Request, err error) {
go m.pingUntilSuccess()
}
func (m *manager) setSentryUserID(userID string) {
m.setSentryUserIDOnce.Do(func() {
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("UserID", userID)
})
})
}

View File

@ -102,6 +102,9 @@ func (m *manager) auth(ctx context.Context, req AuthReq) (*Auth, error) {
}
func (m *manager) authRefresh(ctx context.Context, uid, ref string) (*AuthRefresh, error) {
m.refreshingAuth.Lock()
defer m.refreshingAuth.Unlock()
var req = authRefreshReq{
UID: uid,
RefreshToken: ref,
@ -117,6 +120,9 @@ func (m *manager) authRefresh(ctx context.Context, uid, ref string) (*AuthRefres
_, err := wrapNoConnection(m.r(ctx).SetBody(req).SetResult(&res).Post("/auth/refresh"))
if err != nil {
if IsBadRequest(err) || IsUnprocessableEntity(err) {
err = ErrAuthFailed{err}
}
return nil, err
}

View File

@ -59,16 +59,14 @@ func (m *manager) catchAPIError(_ *resty.Client, res *resty.Response) error {
if apiErr, ok := res.Error().(*Error); ok {
switch {
case apiErr.Code == errCodeUpgradeApplication:
err = ErrUpgradeApplication
if m.cfg.UpgradeApplicationHandler != nil {
m.cfg.UpgradeApplicationHandler()
}
return ErrUpgradeApplication
case apiErr.Code == errCodePasswordWrong:
err = ErrPasswordWrong
return ErrPasswordWrong
case apiErr.Code == errCodeAuthPaidPlanRequired:
err = ErrPaidPlanRequired
case res.StatusCode() == http.StatusUnprocessableEntity:
err = ErrUnprocessableEntity{apiErr}
return ErrPaidPlanRequired
default:
err = apiErr
}
@ -76,6 +74,13 @@ func (m *manager) catchAPIError(_ *resty.Client, res *resty.Response) error {
err = errors.New(res.Status())
}
switch res.StatusCode() {
case http.StatusUnprocessableEntity:
err = ErrUnprocessableEntity{err}
case http.StatusBadRequest:
err = ErrBadRequest{err}
}
return err
}

View File

@ -21,7 +21,6 @@ import (
"context"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/getsentry/sentry-go"
"github.com/go-resty/resty/v2"
"github.com/pkg/errors"
)
@ -126,7 +125,7 @@ func (c *client) UpdateUser(ctx context.Context) (*User, error) {
c.user = user
c.addresses = addresses
sentry.ConfigureScope(func(scope *sentry.Scope) { scope.SetUser(sentry.User{ID: user.ID}) })
c.manager.setSentryUserID(user.ID)
return user, err
}

View File

@ -1,7 +1,7 @@
.PHONY: check-go check-godog install-godog test test-bridge test-live test-live-bridge test-stage test-debug test-live-debug bench
export GO111MODULE=on
export BRIDGE_VERSION:=2.1.0+integrationtests
export BRIDGE_VERSION:=2.1.3+integrationtests
export VERBOSITY?=fatal
export TEST_DATA=testdata

View File

@ -172,3 +172,7 @@ func (ctx *TestContext) MessagePreparationStarted(username string) {
func (ctx *TestContext) MessagePreparationFinished(username string) {
ctx.pmapiController.UnlockEvents(username)
}
func (ctx *TestContext) CredentialsFailsOnWrite(shouldFail bool) {
ctx.credStore.(*fakeCredStore).failOnWrite = shouldFail
}

View File

@ -21,6 +21,7 @@ import (
"strings"
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
"github.com/pkg/errors"
)
// bridgePassword is password to be used for IMAP or SMTP under tests.
@ -28,6 +29,8 @@ const bridgePassword = "bridgepassword"
type fakeCredStore struct {
credentials map[string]*credentials.Credentials
failOnWrite bool
}
// newFakeCredStore returns a fake credentials store (optionally configured with the given credentials).
@ -52,6 +55,9 @@ func (c *fakeCredStore) List() (userIDs []string, err error) {
}
func (c *fakeCredStore) Add(userID, userName, uid, ref string, mailboxPassword []byte, emails []string) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
bridgePassword := bridgePassword
if c, ok := c.credentials[userID]; ok {
bridgePassword = c.BridgePassword
@ -73,14 +79,23 @@ func (c *fakeCredStore) Get(userID string) (*credentials.Credentials, error) {
}
func (c *fakeCredStore) SwitchAddressMode(userID string) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
return c.credentials[userID], nil
}
func (c *fakeCredStore) UpdateEmails(userID string, emails []string) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
return c.credentials[userID], nil
}
func (c *fakeCredStore) UpdatePassword(userID string, password []byte) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
creds, err := c.Get(userID)
if err != nil {
return nil, err
@ -90,6 +105,9 @@ func (c *fakeCredStore) UpdatePassword(userID string, password []byte) (*credent
}
func (c *fakeCredStore) UpdateToken(userID, uid, ref string) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
creds, err := c.Get(userID)
if err != nil {
return nil, err
@ -99,12 +117,18 @@ func (c *fakeCredStore) UpdateToken(userID, uid, ref string) (*credentials.Crede
}
func (c *fakeCredStore) Logout(userID string) (*credentials.Credentials, error) {
if c.failOnWrite {
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
c.credentials[userID].APIToken = ""
c.credentials[userID].MailboxPassword = []byte{}
return c.credentials[userID], nil
}
func (c *fakeCredStore) Delete(userID string) error {
if c.failOnWrite {
return errors.New("An invalid attempt to change the owner of an item. (-25244)")
}
delete(c.credentials, userID)
return nil
}

View File

@ -45,6 +45,8 @@ type PMAPIController interface {
GetCalls(method, path string) [][]byte
LockEvents(username string)
UnlockEvents(username string)
RemoveUserMessageWithoutEvent(username, messageID string) error
RevokeSession(username string) error
}
func newPMAPIController(listener listener.Listener) (PMAPIController, pmapi.Manager) {

View File

@ -234,3 +234,26 @@ func (ctl *Controller) LockEvents(string) {}
// UnlockEvents doesn't needs to be implemented for fakeAPI.
func (ctl *Controller) UnlockEvents(string) {}
func (ctl *Controller) RemoveUserMessageWithoutEvent(username string, messageID string) error {
msgs, ok := ctl.messagesByUsername[username]
if !ok {
return nil
}
for i, message := range msgs {
if message.ID == messageID {
ctl.messagesByUsername[username] = append(msgs[:i], msgs[i+1:]...)
return nil
}
}
return errors.New("message not found")
}
func (ctl *Controller) RevokeSession(username string) error {
for _, session := range ctl.sessionsByUID {
session.uid = "revoked"
}
return nil
}

View File

@ -74,12 +74,12 @@ func (ctl *Controller) createSession(username string, hasFullScope bool) *fakeSe
func (ctl *Controller) refreshSessionIfAuthorized(uid, ref string) (*fakeSession, error) {
session, ok := ctl.sessionsByUID[uid]
if !ok {
return nil, pmapi.ErrUnauthorized
if !ok || session.uid != uid {
return nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad uid")}
}
if ref != session.ref {
return nil, pmapi.ErrUnauthorized
return nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad refresh token")}
}
session.ref = ctl.tokenGenerator.next("ref")

View File

@ -133,14 +133,32 @@ func (api *FakePMAPI) authRefresh() error {
session, err := api.controller.refreshSessionIfAuthorized(api.uid, api.ref)
if err != nil {
if pmapi.IsFailedAuth(err) {
go api.handleAuth(nil)
}
return err
}
api.ref = session.ref
api.acc = session.acc
go api.handleAuth(&pmapi.AuthRefresh{
UID: api.uid,
AccessToken: api.acc,
RefreshToken: api.ref,
ExpiresIn: 7200,
Scopes: []string{"full", "self", "user", "mail"},
})
return nil
}
func (api *FakePMAPI) handleAuth(auth *pmapi.AuthRefresh) {
for _, handle := range api.authHandlers {
handle(auth)
}
}
func (api *FakePMAPI) setUser(username string) error {
api.username = username
api.log = api.log.WithField("username", username)

View File

@ -69,7 +69,7 @@ func (m *fakePMAPIManager) NewClientWithRefresh(_ context.Context, uid, ref stri
session, err := m.controller.refreshSessionIfAuthorized(uid, ref)
if err != nil {
return nil, nil, pmapi.ErrUnauthorized
return nil, nil, err
}
user, ok := m.controller.usersByUsername[session.username]

View File

@ -37,7 +37,7 @@ func (api *FakePMAPI) GetMessage(_ context.Context, apiID string) (*pmapi.Messag
if msg := api.getMessage(apiID); msg != nil {
return msg, nil
}
return nil, fmt.Errorf("message %s not found", apiID)
return nil, pmapi.ErrUnprocessableEntity{OriginalError: fmt.Errorf("message %s not found", apiID)}
}
// ListMessages does not implement following filters:

View File

@ -82,6 +82,10 @@ func (api *FakePMAPI) UpdateUser(context.Context) (*pmapi.User, error) {
return nil, err
}
if err := api.checkAndRecordCall(GET, "/addresses", nil); err != nil {
return nil, err
}
return api.user, nil
}

View File

@ -177,3 +177,14 @@ Feature: IMAP fetch messages
# We had bug to incorectly set empty date, so let's make sure
# there is no reference anywhere in the response.
And IMAP response does not contain "\nDate: Thu, 01 Jan 1970"
Scenario: Fetch of message which was deleted without event processed
Given there are 10 messages in mailbox "INBOX" for "user"
And message "5" was deleted forever without event processed for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
When IMAP client fetches bodies "1:*"
Then IMAP response is "NO"
When IMAP client fetches bodies "1:*"
Then IMAP response is "OK"
And IMAP response has 9 messages

View File

@ -79,3 +79,12 @@ Feature: Start bridge
And "user" does not have loaded store
And "user" does not have running event loop
And "user" has zero space
Scenario: Start with connected user, database file and internet connection, but no write access to credentials
Given there is user "user" which just logged in
And credentials are locked
And there is database file for "user"
When bridge starts
Then "user" is connected
When IMAP client authenticates "user"
Then IMAP response is "NO"

View File

@ -0,0 +1,17 @@
Feature: Session deleted on API
@ignore-live
Scenario: Session revoked after start
Given there is connected user "user"
When session was revoked for "user"
And the event loop of "user" loops once
Then "user" is disconnected
@ignore-live
Scenario: Starting with revoked session
Given there is user "user" which just logged in
And session was revoked for "user"
When bridge starts
Then "user" is disconnected

View File

@ -104,3 +104,14 @@ func (ctl *Controller) GetMessages(username, labelID string) ([]*pmapi.Message,
return messages, nil
}
func (ctl *Controller) RemoveUserMessageWithoutEvent(username string, messageID string) error {
client, err := getPersistentClient(username)
if err != nil {
return err
}
addMessageIDToSkipEventOnceDeleted(messageID)
return client.DeleteMessages(context.Background(), []string{messageID})
}

View File

@ -48,7 +48,8 @@ var persistentClients = struct {
byName map[string]clientAuthGetter
saltByName map[string]string
eventsPaused sync.WaitGroup
eventsPaused sync.WaitGroup
skipDeletedMessageID map[string]struct{}
}{}
type persistentClient struct {
@ -79,7 +80,40 @@ func (pc *persistentClient) GetEvent(ctx context.Context, eventID string) (*pmap
if !ok {
return nil, errors.New("cannot convert to normal client")
}
return normalClient.GetEvent(ctx, eventID)
event, err := normalClient.GetEvent(ctx, eventID)
if err != nil {
return event, err
}
return skipDeletedMessageIDs(event), nil
}
func addMessageIDToSkipEventOnceDeleted(msgID string) {
if persistentClients.skipDeletedMessageID == nil {
persistentClients.skipDeletedMessageID = map[string]struct{}{}
}
persistentClients.skipDeletedMessageID[msgID] = struct{}{}
}
func skipDeletedMessageIDs(event *pmapi.Event) *pmapi.Event {
if len(event.Messages) == 0 {
return event
}
n := 0
for i, m := range event.Messages {
if _, ok := persistentClients.skipDeletedMessageID[m.ID]; ok && m.Action == pmapi.EventDelete {
delete(persistentClients.skipDeletedMessageID, m.ID)
continue
}
event.Messages[i] = m
n++
}
event.Messages = event.Messages[:n]
return event
}
func SetupPersistentClients() {

View File

@ -60,3 +60,7 @@ func (ctl *Controller) GetAuthClient(username string) pmapi.Client {
}
return client
}
func (ctl *Controller) RevokeSession(username string) error {
return errors.New("revoke live session not implemented")
}

View File

@ -38,6 +38,7 @@ func StoreSetupFeatureContext(s *godog.ScenarioContext) {
s.Step(`^there are messages for "([^"]*)" as follows$`, thereAreSomeMessagesForUserAsFollows)
s.Step(`^there are (\d+) messages in mailbox(?:es)? "([^"]*)" for address "([^"]*)" of "([^"]*)"$`, thereAreSomeMessagesInMailboxesForAddressOfUser)
s.Step(`^wait for Sphinx to create duplication indices$`, waitForSphinx)
s.Step(`^message(?:s)? "([^"]*)" (?:was|were) deleted forever without event processed for "([^"]*)"$`, messageWasDeletedWithoutEvent)
}
func thereIsUserWithMailboxes(bddUserID string, mailboxes *godog.Table) error {
@ -319,3 +320,16 @@ func waitForSphinx() error {
time.Sleep(15 * time.Second)
return nil
}
func messageWasDeletedWithoutEvent(bddMessageID, bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
apiID, err := ctx.GetAPIMessageID(account.Username(), bddMessageID)
if err != nil {
return internalError(err, "getting BDD message ID %s", bddMessageID)
}
return ctx.GetPMAPIController().RemoveUserMessageWithoutEvent(account.Username(), apiID)
}

View File

@ -29,6 +29,7 @@ func UsersActionsFeatureContext(s *godog.ScenarioContext) {
s.Step(`^user deletes "([^"]*)"$`, userDeletesUser)
s.Step(`^user deletes "([^"]*)" with cache$`, userDeletesUserWithCache)
s.Step(`^"([^"]*)" swaps address "([^"]*)" with address "([^"]*)"$`, swapsAddressWithAddress)
s.Step(`^session was revoked for "([^"]*)"$`, sessionRevoked)
}
func userLogsIn(bddUserID string) error {
@ -123,3 +124,8 @@ func swapsAddressWithAddress(bddUserID, bddAddressID1, bddAddressID2 string) err
return ctx.GetPMAPIController().ReorderAddresses(account.User(), addressIDs)
}
func sessionRevoked(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
return ctx.GetPMAPIController().RevokeSession(account.Username())
}

View File

@ -33,6 +33,7 @@ func UsersSetupFeatureContext(s *godog.ScenarioContext) {
s.Step(`^there is database file for "([^"]*)"$`, thereIsDatabaseFileForUser)
s.Step(`^there is no database file for "([^"]*)"$`, thereIsNoDatabaseFileForUser)
s.Step(`^there is "([^"]*)" in "([^"]*)" address mode$`, thereIsUserWithAddressMode)
s.Step(`^credentials? (?:are|is) locked$`, credentialsAreLocked)
}
func thereIsUser(bddUserID string) error {
@ -150,3 +151,8 @@ func thereIsUserWithAddressMode(bddUserID, wantAddressMode string) error {
ctx.EventuallySyncIsFinishedForUsername(user.Username())
return nil
}
func credentialsAreLocked() error {
ctx.CredentialsFailsOnWrite(true)
return nil
}