From 289dfad6a51f00ae85520f7e2efd673d22408d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20P=C3=89RONNET?= Date: Tue, 6 Feb 2024 16:49:23 +0100 Subject: [PATCH] feat(login) Add login methods and example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Pierre PÉRONNET --- .github/workflows/test.yml | 37 +++ .golangci.yml | 46 ++++ go.mod | 60 +++++ go.sum | 305 ++++++++++++++++++++++++ login/access_generator.go | 132 ++++++++++ login/chrome/README.md | 7 + login/chrome/chrome.go | 127 ++++++++++ login/chrome/chrome_suite_test.go | 15 ++ login/chrome/chrome_test.go | 129 ++++++++++ login/chrome/logger.go | 36 +++ login/chrome/login_method.go | 139 +++++++++++ login/chrome/options.go | 241 +++++++++++++++++++ login/chrome/options_linux.go | 9 + login/chrome/screen_credentials.go | 83 +++++++ login/chrome/screen_final.go | 150 ++++++++++++ login/chrome/screen_first.go | 74 ++++++ login/chrome/screen_otp.go | 105 ++++++++ login/chrome/screen_privacy.go | 51 ++++ login/chrome/screen_trusted_device.go | 47 ++++ login/chrome/screens.go | 122 ++++++++++ login/chrome/screenshot.go | 41 ++++ login/chrome/utils_test.go | 23 ++ login/config/config.go | 124 ++++++++++ login/config/configfakes/fake_getter.go | 116 +++++++++ login/config/configfakes/fake_setter.go | 78 ++++++ login/config/mapper.go | 33 +++ login/config/tools.go | 7 + login/server.go | 192 +++++++++++++++ login/server_test.go | 163 +++++++++++++ login/suite_test.go | 15 ++ login/types.go | 53 ++++ settings/settings.go | 9 + v1/client.go | 118 ++++++--- v1/document.go | 112 ++++----- v1/document_test.go | 36 +++ v1/example_test.go | 108 +++++++++ v1/folder.go | 12 +- v1/folder_example_test.go | 236 ++++++++++++++++++ v1/folder_test.go | 29 +++ v1/share.go | 17 +- v1/testdata/document.txt | 4 + v1/v1_suite_test.go | 145 +++++++++++ 42 files changed, 3476 insertions(+), 110 deletions(-) create mode 100644 go.sum create mode 100644 login/access_generator.go create mode 100644 login/chrome/README.md create mode 100644 login/chrome/chrome.go create mode 100644 login/chrome/chrome_suite_test.go create mode 100644 login/chrome/chrome_test.go create mode 100644 login/chrome/logger.go create mode 100644 login/chrome/login_method.go create mode 100644 login/chrome/options.go create mode 100644 login/chrome/options_linux.go create mode 100644 login/chrome/screen_credentials.go create mode 100644 login/chrome/screen_final.go create mode 100644 login/chrome/screen_first.go create mode 100644 login/chrome/screen_otp.go create mode 100644 login/chrome/screen_privacy.go create mode 100644 login/chrome/screen_trusted_device.go create mode 100644 login/chrome/screens.go create mode 100644 login/chrome/screenshot.go create mode 100644 login/chrome/utils_test.go create mode 100644 login/config/config.go create mode 100644 login/config/configfakes/fake_getter.go create mode 100644 login/config/configfakes/fake_setter.go create mode 100644 login/config/mapper.go create mode 100644 login/config/tools.go create mode 100644 login/server.go create mode 100644 login/server_test.go create mode 100644 login/suite_test.go create mode 100644 login/types.go create mode 100644 settings/settings.go create mode 100644 v1/document_test.go create mode 100644 v1/example_test.go create mode 100644 v1/folder_example_test.go create mode 100644 v1/folder_test.go create mode 100644 v1/testdata/document.txt create mode 100644 v1/v1_suite_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e942bc..1dfdf3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,9 +20,46 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.19' - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: install-mode: "binary" version: v1.55 args: --timeout=30m + + tests: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.19' + + - name: Cache browser binaries + id: cache-browser + uses: actions/cache@v3 + with: + path: ~/.cache/rod/browser + key: ${{ runner.os }}-rod-browser + + - name: Test + run: go test -v ./v1/... -ginkgo.v + env: + DIGIPOSTE_API: ${{ vars.DIGIPOSTE_API }} + DIGIPOSTE_URL: ${{ vars.DIGIPOSTE_URL }} + DIGIPOSTE_USERNAME: ${{ secrets.DIGIPOSTE_USERNAME }} + DIGIPOSTE_PASSWORD: ${{ secrets.DIGIPOSTE_PASSWORD }} + DIGIPOSTE_OTP_SECRET: ${{ secrets.DIGIPOSTE_OTP_SECRET }} + + - name: Get debug screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: screenshots + path: '**/*.png' diff --git a/.golangci.yml b/.golangci.yml index 856b221..098978f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,3 +4,49 @@ linters: enable-all: true disable: - tagliatelle # Digiposte API is not consistent, it use snake_case and camelCase + - nosnakecase + +linters-settings: + # Gci controls Go package import order and makes it always deterministic. + gci: + # Section configuration to compare against. + # Section names are case-insensitive and may contain parameters in (). + # The default order of sections is `standard > default > custom > blank > dot > alias`, + # If `custom-order` is `true`, it follows the order of `sections` option. + # Default: ["standard", "default"] + sections: + - standard # Standard section: captures all standard packages. + - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. + - default # Default section: contains all imports that could not be matched to another section type. + - prefix(github.com/holyhope/digiposte-go-sdk) # Custom section: groups all imports with the specified Prefix. + - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. + + depguard: + # Default: Only allow $gostd in all files. + rules: + # Name of a rule. + main: + files: + - "$all" + - "!$test" + list-mode: lax + allow: + - $gostd + - github.com/chromedp + - github.com/holyhope + - github.com/Davincible/chromedp-undetected + - github.com/go-oauth2/oauth2/v4 + - github.com/pquerna/otp + + # Name of a rule. + tests: + files: + - "$test" + list-mode: lax + allow: + - $gostd + - github.com/holyhope + - github.com/onsi/ginkgo + - github.com/onsi/gomega + - github.com/go-oauth2/oauth2/v4 + - github.com/go-rod/rod/lib/launcher diff --git a/go.mod b/go.mod index 53219d6..9389415 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,63 @@ module github.com/holyhope/digiposte-go-sdk go 1.19 + +require ( + github.com/Davincible/chromedp-undetected v1.3.8 + github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 + github.com/chromedp/chromedp v0.9.3 + github.com/go-oauth2/oauth2/v4 v4.5.2 + github.com/go-rod/rod v0.114.7 + github.com/maxbrunsfeld/counterfeiter/v6 v6.7.0 + github.com/onsi/ginkgo/v2 v2.13.0 + github.com/onsi/gomega v1.30.0 + github.com/pquerna/otp v1.4.0 + golang.org/x/oauth2 v0.13.0 + golang.org/x/time v0.5.0 +) + +require ( + github.com/Xuanwo/go-locale v1.1.0 // indirect + github.com/boombuler/barcode v1.0.1 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.3.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/tidwall/btree v1.7.0 // indirect + github.com/tidwall/buntdb v1.3.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/grect v0.1.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/rtred v0.1.2 // indirect + github.com/tidwall/tinyqueue v0.1.1 // indirect + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.8.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7c0073f --- /dev/null +++ b/go.sum @@ -0,0 +1,305 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Davincible/chromedp-undetected v1.3.8 h1:Glt5Faaz3oeDrBZs1NPWjqJeSnbFCMkBvCCFqbrJHnE= +github.com/Davincible/chromedp-undetected v1.3.8/go.mod h1:8ThyCTNGAhCc9I8q3fA5lunyNiFMaLcvhL0wpxWUi7A= +github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg= +github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 h1:XD280QPATe9jaz20dylKe3vBsNcH1w3mkssGY0lidn8= +github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg= +github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-oauth2/oauth2/v4 v4.5.2 h1:CuZhD3lhGuI6aNLyUbRHXsgG2RwGRBOuCBfd4WQKqBQ= +github.com/go-oauth2/oauth2/v4 v4.5.2/go.mod h1:wk/2uLImWIa9VVQDgxz99H2GDbhmfi/9/Xr+GvkSUSQ= +github.com/go-rod/rod v0.114.7 h1:h4pimzSOUnw7Eo41zdJA788XsawzHjJMyzCE3BrBww0= +github.com/go-rod/rod v0.114.7/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw= +github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU= +github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20231212022811-ec68065c825e h1:bwOy7hAFd0C91URzMIEBfr6BAz29yk7Qj0cy6S7DJlU= +github.com/google/pprof v0.0.0-20231212022811-ec68065c825e/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/maxbrunsfeld/counterfeiter/v6 v6.7.0 h1:z0CfPybq3CxaJvrrpf7Gme1psZTqHhJxf83q6apkSpI= +github.com/maxbrunsfeld/counterfeiter/v6 v6.7.0/go.mod h1:RVP6/F85JyxTrbJxWIdKU2vlSvK48iCMnMXRkSz7xtg= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= +github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY= +github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= +github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= +github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI= +github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= +github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI= +github.com/tidwall/buntdb v1.3.0 h1:gdhWO+/YwoB2qZMeAU9JcWWsHSYU3OvcieYgFRS0zwA= +github.com/tidwall/buntdb v1.3.0/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= +github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= +github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= +github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= +github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= +github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8= +github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ= +github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= +github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= +github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE= +github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= +github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.0.2 h1:VuWweTmXK+zedLqYufJdh3PlxDNBOfFHjIZlPT2T5nw= +github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/got v0.34.1 h1:IrV2uWLs45VXNvZqhJ6g2nIhY+pgIG1CUoOcqfXFl1s= +github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.8.0 h1:BzLrVoiwxikpgEQR0Lk8NyBN5Cit2b1z+u0mgL4ZJak= +github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/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-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/access_generator.go b/login/access_generator.go new file mode 100644 index 0000000..9cdf076 --- /dev/null +++ b/login/access_generator.go @@ -0,0 +1,132 @@ +package login + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "sync" + + oauth2v4 "github.com/go-oauth2/oauth2/v4" + "golang.org/x/oauth2" + + digiconfig "github.com/holyhope/digiposte-go-sdk/login/config" +) + +// AccessGenerator is an oauth2.AccessGenerate that uses a LoginMethod to generate the access token. +type AccessGenerator struct { + setter digiconfig.Setter + loginMethod Method + credentials *sync.Map +} + +var _ oauth2v4.AccessGenerate = (*AccessGenerator)(nil) + +const RefreshTokenLength = 32 + +func (ag *AccessGenerator) SetCredentials(clientID string, creds *Credentials) { + ag.credentials.Store(clientID, creds) +} + +func (ag *AccessGenerator) Token( + ctx context.Context, + generateBasic *oauth2v4.GenerateBasic, + isGenRefresh bool, +) (string, string, error) { + digiposteToken, cookies, err := ag.login(ctx, generateBasic) + if err != nil { + return "", "", fmt.Errorf("login: %w", err) + } + + if err := digiconfig.SetCookies(ag.setter, cookies); err != nil { + return "", "", fmt.Errorf("set cookies: %w", err) + } + + if !isGenRefresh { + return digiposteToken.AccessToken, "", nil + } + + if digiposteToken.RefreshToken == "" { + refreshTokenRunes := make([]byte, base64.RawStdEncoding.DecodedLen(RefreshTokenLength)) + if _, err := rand.Read(refreshTokenRunes); err != nil { + return "", "", fmt.Errorf("generate refresh token: %w", err) + } + + refreshToken := bytes.NewBuffer(make([]byte, 0, RefreshTokenLength)) + + enc := base64.NewEncoder(base64.StdEncoding, refreshToken) + defer enc.Close() + + if _, err := enc.Write(refreshTokenRunes); err != nil { + return "", "", fmt.Errorf("encode refresh token: %w", err) + } + + digiposteToken.RefreshToken = refreshToken.String() + } + + return digiposteToken.AccessToken, digiposteToken.RefreshToken, nil +} + +func (ag *AccessGenerator) login( + ctx context.Context, + generateBasic *oauth2v4.GenerateBasic, +) (*oauth2.Token, []*http.Cookie, error) { + var creds *Credentials + + if value, ok := ag.credentials.Load(generateBasic.Client.GetID()); ok { + value, ok := value.(*Credentials) + if !ok { + return nil, nil, &InvalidCredentialsError{value: value} + } + + creds = value + } + + if err := areCredentialsValid(creds); err != nil { + return nil, nil, fmt.Errorf("invalid credentials: %w", err) + } + + digiposteToken, cookies, err := ag.loginMethod.Login(ctx, creds) + if err != nil { + return nil, nil, fmt.Errorf("using %v: %w", ag.loginMethod, err) + } + + return digiposteToken, cookies, nil +} + +type InvalidCredentialsError struct { + value interface{} +} + +func (e *InvalidCredentialsError) Error() string { + return fmt.Sprintf("invalid credentials: got %T expected *Credentials", e.value) +} + +var ErrNilCredentials = errors.New("nil credentials") + +func areCredentialsValid(creds *Credentials) error { + if creds == nil { + return ErrNilCredentials + } + + if creds.Username == "" { + return &RequiredFieldError{Field: "username"} + } + + if creds.Password == "" { + return &RequiredFieldError{Field: "password"} + } + + return nil +} + +type RequiredFieldError struct { + Field string +} + +func (e *RequiredFieldError) Error() string { + return fmt.Sprintf("missing field %q", e.Field) +} diff --git a/login/chrome/README.md b/login/chrome/README.md new file mode 100644 index 0000000..0742c2a --- /dev/null +++ b/login/chrome/README.md @@ -0,0 +1,7 @@ +Digiposte login module +====================== + +This module is used to login to digiposte.fr. + +This module exists because digiposte.fr does not provide oauth2 credentials to third party applications. +This module will get the credentials from the digiposte.fr website and store them in the rclone config file. diff --git a/login/chrome/chrome.go b/login/chrome/chrome.go new file mode 100644 index 0000000..3ab6759 --- /dev/null +++ b/login/chrome/chrome.go @@ -0,0 +1,127 @@ +package chrome + +import ( + "context" + "fmt" + "log" + "net/http" + "sync/atomic" + "time" + + "golang.org/x/oauth2" + + "github.com/holyhope/digiposte-go-sdk/login" +) + +func (c *chromeLogin) login( //nolint:nonamedreturns + parentCtx, independentChromeCtx context.Context, + creds *login.Credentials, +) (_ *oauth2.Token, _ []*http.Cookie, finalErr error) { + if c.timeout > 0 { + ctx, cancel := context.WithTimeout(parentCtx, c.timeout) + defer cancel() + + parentCtx = ctx + } + + ctx, cancel := WithCancelOnClose(independentChromeCtx, parentCtx.Done()) + defer cancel() + + defer c.ScreenshotIfNeeded(independentChromeCtx, &finalErr) + + if err := resolve(ctx, &firstScreen{ + URL: c.url, + }); err != nil { + return nil, nil, fmt.Errorf("first screen: %w", err) + } + + infoLogger(ctx).Printf("Page %q loaded\n", c.url) + + return c.resolveLogin(ctx, creds) +} + +func WithCancelOnClose(ctx context.Context, done <-chan struct{}) (context.Context, context.CancelFunc) { + attachedChromeCtx, cancel := context.WithCancel(ctx) + + go func(independentChromeCtx context.Context, done <-chan struct{}, cancel context.CancelFunc) { + select { + case <-independentChromeCtx.Done(): // do nothing + case <-done: + cancel() + } + }(ctx, done, cancel) + + return attachedChromeCtx, cancel +} + +func (c *chromeLogin) resolveLogin( + ctx context.Context, + creds *login.Credentials, +) (*oauth2.Token, []*http.Cookie, error) { + finalScreen := &finalScreen{ + Token: nil, + Cookies: nil, + } + + screens := Screens{ + screens: []Screen{ + &privacyScreen{ + AcceptCookies: false, + }, + &credentialsScreen{ + Username: creds.Username, + Password: creds.Password, + }, + &otpScreen{ + Secret: creds.OTPSecret, + }, + &trustedDeviceScreen{}, + finalScreen, + }, + refreshFrequency: c.refreshFrequency, + succeeded: atomic.Bool{}, + } + + go screens.Resolve(ctx) + + ticker := time.NewTicker(c.refreshFrequency) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, nil, fmt.Errorf("context done: %w", ctx.Err()) + + case <-ticker.C: + if finalScreen.Token != nil { + screens.succeeded.Store(true) + + return finalScreen.Token, finalScreen.Cookies, nil + } + } + } +} + +type chromeLogin struct { + url string + + cookies []*http.Cookie + + screenShortOnError bool + refreshFrequency time.Duration + timeout time.Duration + + infoLogger *log.Logger + errorLogger *log.Logger + + binaryPath string +} + +type HTTPError struct { + Status int64 + StatusText string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP error %d: %s", e.Status, e.StatusText) +} diff --git a/login/chrome/chrome_suite_test.go b/login/chrome/chrome_suite_test.go new file mode 100644 index 0000000..ad04137 --- /dev/null +++ b/login/chrome/chrome_suite_test.go @@ -0,0 +1,15 @@ +package chrome_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestRcloneDigiposteLogin(t *testing.T) { + t.Parallel() + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "RcloneDigiposteLogin Suite") +} diff --git a/login/chrome/chrome_test.go b/login/chrome/chrome_test.go new file mode 100644 index 0000000..7e52239 --- /dev/null +++ b/login/chrome/chrome_test.go @@ -0,0 +1,129 @@ +package chrome_test + +import ( + "fmt" + "log" + "os" + "path" + "time" + + "github.com/go-rod/rod/lib/launcher" + + "github.com/holyhope/digiposte-go-sdk/login" + "github.com/holyhope/digiposte-go-sdk/login/chrome" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive +) + +var _ = Describe("Login", func() { + Context("With valid options", func() { + var username, password, otpSecret string + + var debugScreenshot []byte + + var chromeMethod login.Method + + BeforeEach(func() { + username = os.Getenv("DIGIPOSTE_USERNAME") + if len(username) == 0 { + Skip("missing DIGIPOSTE_USERNAME") + } + + password = os.Getenv("DIGIPOSTE_PASSWORD") + if len(password) == 0 { + Skip("missing DIGIPOSTE_PASSWORD") + } + + otpSecret = os.Getenv("DIGIPOSTE_OTP_SECRET") + + chromeBinary, err := launcher.NewBrowser().Get() + Expect(err).ToNot(HaveOccurred()) + + loginWithChrome, err := chrome.New( + chrome.WithURL(os.Getenv("DIGIPOSTE_URL")), + chrome.WithCookies(nil), + chrome.WithRefreshFrequency(500*time.Millisecond), // Reduce the test duration + chrome.WithLoggers( + log.New(GinkgoWriter, "[INFO] ", log.Ldate|log.Ltime|log.Lmsgprefix), + log.New(GinkgoWriter, "[ERRO] ", log.Ldate|log.Ltime|log.Lmsgprefix), + ), + chrome.WithScreenShortOnError(), + chrome.WithTimeout(3*time.Minute), + chrome.WithBinary(chromeBinary), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(loginWithChrome).ToNot(BeNil()) + + chromeMethod = loginWithChrome + }) + + AfterEach(func() { + if len(debugScreenshot) == 0 { + return + } + + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + screenshotPath := path.Join(cwd, CurrentSpecReport().FullText()+".png") + defer Expect(os.WriteFile( + screenshotPath, // Use GinkgoT().TempDir() instead? + debugScreenshot, + 0o600, + )).To(Succeed()) + + fmt.Fprintf(GinkgoWriter, "Screenshot saved to %q\n", screenshotPath) + }) + + It("Should work", func(ctx SpecContext) { + token, cookies, err := chromeMethod.Login( + ctx, + &login.Credentials{ + Username: username, + Password: password, + OTPSecret: otpSecret, + }, + ) + if err != nil { + if screenshot, ok := chrome.GetScreenShot(err); ok { + debugScreenshot = screenshot + } + } + Expect(err).ToNot(HaveOccurred()) + Expect(token.Valid()).To(BeTrue()) + Expect(cookies).ToNot(BeEmpty()) + + fmt.Fprintf(GinkgoWriter, "Token expires at %v\n", token.Expiry.Local()) + }) + }) + + Context("With invalid options", func() { + Describe("Empty URL", func() { + It("Should return an error", func() { + _, err := chrome.New( + chrome.WithURL(""), + ) + Expect(err).To(MatchError(HaveSuffix(`option "WithURL": url is empty`))) + }) + }) + + Describe("Negative refresh frequency", func() { + It("Should return an error", func() { + _, err := chrome.New( + chrome.WithRefreshFrequency(-1), + ) + Expect(err).To(MatchError(HaveSuffix(`option "WithRefreshFrequency": frequency must be positive`))) + }) + }) + + Describe("Negative timeout", func() { + It("Should return an error", func() { + _, err := chrome.New( + chrome.WithTimeout(-1), + ) + Expect(err).To(MatchError(HaveSuffix(`option "WithTimeout": timeout must be positive`))) + }) + }) + }) +}) diff --git a/login/chrome/logger.go b/login/chrome/logger.go new file mode 100644 index 0000000..6c26526 --- /dev/null +++ b/login/chrome/logger.go @@ -0,0 +1,36 @@ +package chrome + +import ( + "context" + "log" +) + +var contextErrorLoggerKey = "error-logger" //nolint:gochecknoglobals + +func errorLogger(ctx context.Context) *log.Logger { + logger, ok := ctx.Value(&contextErrorLoggerKey).(*log.Logger) + if !ok { + panic("no error logger") + } + + return log.New(logger.Writer(), logger.Prefix(), logger.Flags()) +} + +func withErrorLogger(ctx context.Context, logger *log.Logger) context.Context { + return context.WithValue(ctx, &contextErrorLoggerKey, log.New(logger.Writer(), logger.Prefix(), logger.Flags())) +} + +var contextInfoLoggerKey = "info-logger" //nolint:gochecknoglobals + +func infoLogger(ctx context.Context) *log.Logger { + logger, ok := ctx.Value(&contextInfoLoggerKey).(*log.Logger) + if !ok { + panic("no info logger") + } + + return log.New(logger.Writer(), logger.Prefix(), logger.Flags()) +} + +func withInfoLogger(ctx context.Context, logger *log.Logger) context.Context { + return context.WithValue(ctx, &contextInfoLoggerKey, log.New(logger.Writer(), logger.Prefix(), logger.Flags())) +} diff --git a/login/chrome/login_method.go b/login/chrome/login_method.go new file mode 100644 index 0000000..9354278 --- /dev/null +++ b/login/chrome/login_method.go @@ -0,0 +1,139 @@ +package chrome + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + cu "github.com/Davincible/chromedp-undetected" + "github.com/chromedp/chromedp" + "golang.org/x/oauth2" + + login "github.com/holyhope/digiposte-go-sdk/login" + "github.com/holyhope/digiposte-go-sdk/settings" +) + +// New creates a new chrome login method. +// It requires chromedp to be installed. +func New(opts ...login.Option) (login.Method, error) { //nolint:ireturn + for i, opt := range opts { + if opt, ok := opt.(Validatable); ok { + if err := opt.Validate(); err != nil { + return nil, fmt.Errorf("validate option %d: %w", i, err) + } + } + } + + return &chromeMethod{ + opts: opts, + }, nil +} + +type chromeMethod struct { + opts []login.Option +} + +var _ login.Method = (*chromeMethod)(nil) + +// Login logs in to digiposte using chrome. +func (c *chromeMethod) Login(ctx context.Context, creds *login.Credentials) (*oauth2.Token, []*http.Cookie, error) { + independentChromeCtx, chrome, cancel, err := c.newChromeLogin(ctx) + if err != nil { + return nil, nil, fmt.Errorf("new chrome login: %w", err) + } + + defer cancel() + + if err := chromedp.Run(independentChromeCtx); err != nil { + return nil, nil, fmt.Errorf("init chrome: %w", err) + } + + defer closeChrome(independentChromeCtx) + + pid := chromedp.FromContext(independentChromeCtx).Browser.Process().Pid + + infoLogger(independentChromeCtx).Printf("Chrome started. PID: %d...\n", pid) + + return chrome.login(ctx, independentChromeCtx, creds) +} + +func (c *chromeMethod) String() string { + return "chrome" +} + +const ( + // DefaultRefreshFrequency is the default refresh frequency for the login process. + DefaultRefreshFrequency = 1500 * time.Millisecond +) + +func (c *chromeMethod) newChromeLogin( + _ context.Context, +) (context.Context, *chromeLogin, context.CancelFunc, error) { + chrome := &chromeLogin{ + refreshFrequency: DefaultRefreshFrequency, + url: settings.DefaultDocumentURL, + cookies: nil, + screenShortOnError: false, + infoLogger: log.Default(), + errorLogger: log.Default(), + timeout: 0, + binaryPath: "", + } + + for i, opt := range c.opts { + if err := opt.Apply(chrome); err != nil { + return nil, nil, nil, fmt.Errorf("apply option %d: %w", i, err) + } + } + + // Note: Do not inherit the context, so that we can cancel it independently. + independentChromeCtx, cancelCtx := context.WithCancel(context.Background()) + + independentChromeCtx = withInfoLogger(independentChromeCtx, chrome.infoLogger) + independentChromeCtx = withErrorLogger(independentChromeCtx, chrome.errorLogger) + + independentChromeCtx, cancelChrome, err := cu.New(cu.NewConfig(append(chromeOpts, + cu.WithContext(independentChromeCtx), + cu.WithChromeBinary(chrome.binaryPath), + func(c *cu.Config) { + c.ContextOptions = append(c.ContextOptions, + chromedp.WithErrorf(chrome.errorLogger.Printf), + chromedp.WithLogf(chrome.infoLogger.Printf), + chromedp.WithDebugf(func(s string, i ...interface{}) { + // do nothing + }), + ) + }, + )...)) + if err != nil { + cancelCtx() + + return nil, nil, nil, fmt.Errorf("new chromedp context: %w", err) + } + + return independentChromeCtx, chrome, func() { + cancelChrome() + cancelCtx() + }, nil +} + +const cancellationTimeout = 5 * time.Second + +func closeChrome(ctx context.Context) { + proc := chromedp.FromContext(ctx).Browser.Process() + + ctx, cancel := context.WithTimeout(ctx, cancellationTimeout) + defer cancel() + + if err := chromedp.Cancel(ctx); err != nil { + lgr := errorLogger(ctx) + + lgr.Printf("Failed to cancel chrome: %v\n", err) + + if err := proc.Kill(); err != nil { + lgr.Printf("Failed to kill chrome: %v\n", err) + } + } +} diff --git a/login/chrome/options.go b/login/chrome/options.go new file mode 100644 index 0000000..d693fba --- /dev/null +++ b/login/chrome/options.go @@ -0,0 +1,241 @@ +package chrome + +import ( + "errors" + "fmt" + "log" + "net/http" + "reflect" + "time" + + cu "github.com/Davincible/chromedp-undetected" + + login "github.com/holyhope/digiposte-go-sdk/login" +) + +type Validatable interface { + Validate() error +} + +var chromeOpts = []cu.Option{ //nolint:gochecknoglobals + func(c *cu.Config) { + c.Language = "fr-FR" + }, +} + +var errNegativeFreq = fmt.Errorf("frequency must be positive") + +func WithRefreshFrequency(frequency time.Duration) login.Option { //nolint:ireturn + return &withRefreshFrequency{Frequency: frequency} +} + +type withRefreshFrequency struct { + Frequency time.Duration +} + +func (o *withRefreshFrequency) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + chrome.refreshFrequency = o.Frequency + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +func (o *withRefreshFrequency) Validate() error { + if o.Frequency <= 0 { + return &login.InvalidOptionError{ + Name: "WithRefreshFrequency", + Err: errNegativeFreq, + } + } + + return nil +} + +var errNegativeTimeout = fmt.Errorf("timeout must be positive") + +func WithTimeout(timeout time.Duration) login.Option { //nolint:ireturn + return &withTimeout{Timeout: timeout} +} + +type withTimeout struct { + Timeout time.Duration +} + +func (o *withTimeout) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + chrome.timeout = o.Timeout + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +func (o *withTimeout) Validate() error { + if o.Timeout <= 0 { + return &login.InvalidOptionError{ + Name: "WithTimeout", + Err: errNegativeTimeout, + } + } + + return nil +} + +var errEmptyURL = errors.New("url is empty") + +func WithURL(url string) login.Option { //nolint:ireturn + return &withURL{URL: url} +} + +type withURL struct { + URL string +} + +func (o *withURL) Validate() error { + if o.URL == "" { + return &login.InvalidOptionError{ + Name: "WithURL", + Err: errEmptyURL, + } + } + + return nil +} + +func (o *withURL) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + chrome.url = o.URL + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +func WithCookies(cookies []*http.Cookie) login.Option { //nolint:ireturn + return &withCookies{Cookies: cookies} +} + +type withCookies struct { + Cookies []*http.Cookie +} + +func (o *withCookies) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + chrome.cookies = o.Cookies + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +func WithScreenShortOnError() login.Option { //nolint:ireturn + return &withScreenShortOnError{} +} + +type withScreenShortOnError struct{} + +func (o *withScreenShortOnError) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + chrome.screenShortOnError = true + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +type InvalidTypeOptionError struct { + instance interface{} +} + +func (e *InvalidTypeOptionError) Error() string { + return fmt.Sprintf("invalid instance type: %T", e.instance) +} + +const ( + ConfigURL = "url" + ConfigCookieJar = "cookie" +) + +type MissingOptionError struct { + Option string +} + +func (e *MissingOptionError) Error() string { + return fmt.Sprintf("missing option %q", e.Option) +} + +func (e *MissingOptionError) Is(target error) bool { + if target, ok := target.(*MissingOptionError); ok { + return reflect.ValueOf(e.Option).Pointer() == reflect.ValueOf(target.Option).Pointer() + } + + return false +} + +type WithScreenshotError struct { + Err error + Screenshot []byte +} + +func (e *WithScreenshotError) Error() string { + return e.Err.Error() +} + +func (e *WithScreenshotError) Unwrap() error { + return e.Err +} + +func WithLoggers(infoLgr, errorLgr *log.Logger) login.Option { //nolint:ireturn + return &withLoggers{ + Info: infoLgr, + Error: errorLgr, + } +} + +type withLoggers struct { + Info *log.Logger + Error *log.Logger +} + +func (o *withLoggers) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + if o.Info != nil { + chrome.infoLogger = o.Info + } + + if o.Error != nil { + chrome.errorLogger = o.Error + } + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} + +func WithBinary(path string) login.Option { //nolint:ireturn + return &withBinary{Path: path} +} + +type withBinary struct { + Path string +} + +func (o *withBinary) Apply(instance interface{}) error { + if chrome, ok := instance.(*chromeLogin); ok { + if o.Path != "" { + chrome.binaryPath = o.Path + } + + return nil + } + + return &InvalidTypeOptionError{instance: instance} +} diff --git a/login/chrome/options_linux.go b/login/chrome/options_linux.go new file mode 100644 index 0000000..17605e5 --- /dev/null +++ b/login/chrome/options_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package chrome + +import cu "github.com/Davincible/chromedp-undetected" + +func init() { //nolint:gochecknoinits + chromeOpts = append(chromeOpts, cu.WithHeadless()) +} diff --git a/login/chrome/screen_credentials.go b/login/chrome/screen_credentials.go new file mode 100644 index 0000000..f320a06 --- /dev/null +++ b/login/chrome/screen_credentials.go @@ -0,0 +1,83 @@ +package chrome + +import ( + "context" + "fmt" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/input" + "github.com/chromedp/chromedp" + "github.com/chromedp/chromedp/kb" +) + +type credentialsScreen struct { + Username string + Password string +} + +var _ Screen = (*credentialsScreen)(nil) + +func (s *credentialsScreen) String() string { + return "credentials screen" +} + +func (s *credentialsScreen) CurrentPageMatches(ctx context.Context) bool { + if s.Username == "" || s.Password == "" { + return false + } + + var nodeIDs []cdp.NodeID + + err := chromedp.Run(ctx, + chromedp.NodeIDs(`form[name=login-form]`, &nodeIDs, chromedp.ByQuery, chromedp.AtLeast(0)), + ) + if err != nil { + errorLogger(ctx).Printf("run: %v\n", err) + + return false + } + + return len(nodeIDs) > 0 +} + +func (s *credentialsScreen) Do(ctx context.Context) error { + if err := (&chromedp.Tasks{ + chromedp.WaitVisible(`#submit`, chromedp.ByID), + chromedp.WaitEnabled(`#submit`, chromedp.ByID), + + chromedp.WaitVisible(`#username`, chromedp.ByID), + chromedp.Click(`#username`, chromedp.ByID), + s.ClearInput(`#username`, chromedp.ByID), + chromedp.SendKeys(`#username`, s.Username, chromedp.ByID), + + chromedp.WaitVisible(`#password`, chromedp.ByID), + chromedp.Click(`#password`, chromedp.ByID), + s.ClearInput(`#password`, chromedp.ByID), + chromedp.SendKeys(`#password`, s.Password, chromedp.ByID), + + chromedp.Click(`#submit`, chromedp.ByID), + }).Do(ctx); err != nil { + return fmt.Errorf("tasks: %w", err) + } + + return nil +} + +func (s *credentialsScreen) ShouldWaitForResponse() bool { + return true +} + +func (s *credentialsScreen) ClearInput(sel interface{}, opts ...chromedp.QueryOption) *chromedp.Tasks { + return &chromedp.Tasks{ + chromedp.Clear(sel, opts...), + + chromedp.SetValue(sel, "", opts...), + + input.DispatchKeyEvent(input.KeyDown).WithKey(kb.Control), + input.DispatchKeyEvent(input.KeyDown).WithKey("a"), + input.DispatchKeyEvent(input.KeyUp).WithKey("a"), + input.DispatchKeyEvent(input.KeyUp).WithKey(kb.Control), + input.DispatchKeyEvent(input.KeyDown).WithKey(kb.Backspace), + input.DispatchKeyEvent(input.KeyUp).WithKey(kb.Backspace), + } +} diff --git a/login/chrome/screen_final.go b/login/chrome/screen_final.go new file mode 100644 index 0000000..ba7e1ec --- /dev/null +++ b/login/chrome/screen_final.go @@ -0,0 +1,150 @@ +package chrome + +import ( + "context" + "fmt" + "math" + "net/http" + "strconv" + "time" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + "golang.org/x/oauth2" +) + +type finalScreen struct { + Token *oauth2.Token + Cookies []*http.Cookie +} + +var _ Screen = (*finalScreen)(nil) + +func (s *finalScreen) String() string { + return "final screen" +} + +func (s *finalScreen) CurrentPageMatches(ctx context.Context) bool { + var nodeIDs []cdp.NodeID + + if err := chromedp.Run(ctx, + chromedp.NodeIDs(`#popin_tc_privacy_button`, &nodeIDs, chromedp.ByID, chromedp.AtLeast(0)), + ); err != nil { + errorLogger(ctx).Printf("run: %v\n", err) + + return false + } + + return len(nodeIDs) > 0 +} + +type InvalidTokenError struct { + Token *oauth2.Token +} + +func (e *InvalidTokenError) Error() string { + return fmt.Sprintf("invalid token: %v", e.Token) +} + +func (s *finalScreen) Do(ctx context.Context) error { + token := new(oauth2.Token) + + var expiryStr string + + infoLogger(ctx).Println("Fetching token from browser...") + + if err := (&chromedp.Tasks{ + chromedp.Poll(`sessionStorage.getItem("access_token")`, &token.AccessToken), + // access_expires_at returns the current time, is this a bug? + chromedp.Poll(`sessionStorage.getItem("app_expires_at")`, &expiryStr), + }).Do(ctx); err != nil { + return fmt.Errorf("fetch token from browser: %w", err) + } + + expiry, err := strconv.ParseFloat(expiryStr, 64) + if err != nil { + return fmt.Errorf("parse access_expires_at: %w", err) + } + + token.Expiry = unixFloat2Time(expiry) + + if !token.Valid() { + return &InvalidTokenError{Token: token} + } + + var ( + cookies []*http.Cookie + currentURL string + ) + + infoLogger(ctx).Println("Fetching cookies from browser...") + + if err := (&chromedp.Tasks{ + chromedp.Location(¤tURL), + chromedp.ActionFunc(func(ctx context.Context) error { + chromeCookies, err := network.GetCookies().Do(ctx) + if err != nil { + return fmt.Errorf("get cookies: %w", err) + } + + cookies = make([]*http.Cookie, 0, len(chromeCookies)) + for _, v := range chromeCookies { + cookies = append(cookies, convertCookie(v)) + } + + return nil + }), + }).Do(ctx); err != nil { + return fmt.Errorf("fetch cookies from browser: %w", err) + } + + infoLogger(ctx).Printf("%d cookies fetched from %q\n", len(cookies), currentURL) + + s.Token = token + s.Cookies = cookies + + return nil +} + +func (s *finalScreen) ShouldWaitForResponse() bool { + return false +} + +func convertCookie(cookie *network.Cookie) *http.Cookie { + var sameSite http.SameSite + + switch cookie.SameSite { + case network.CookieSameSiteLax: + sameSite = http.SameSiteLaxMode + case network.CookieSameSiteStrict: + sameSite = http.SameSiteStrictMode + case network.CookieSameSiteNone: + sameSite = http.SameSiteNoneMode + default: + sameSite = http.SameSiteDefaultMode + } + + return &http.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + Path: cookie.Path, + Domain: cookie.Domain, + Expires: unixFloat2Time(cookie.Expires), + Secure: cookie.Secure, + HttpOnly: cookie.HTTPOnly, + SameSite: sameSite, + Raw: fmt.Sprintf("%s=%s", cookie.Name, cookie.Value), + + RawExpires: "", + MaxAge: 0, + Unparsed: nil, + } +} + +func unixFloat2Time(unix float64) time.Time { + sec := math.Trunc(unix) + nano := (unix - sec) * float64(time.Second/time.Nanosecond) + + return time.Unix(int64(sec), int64(nano)) +} diff --git a/login/chrome/screen_first.go b/login/chrome/screen_first.go new file mode 100644 index 0000000..103f3da --- /dev/null +++ b/login/chrome/screen_first.go @@ -0,0 +1,74 @@ +package chrome + +import ( + "context" + "fmt" + + "github.com/chromedp/chromedp" +) + +type firstScreen struct { + URL string +} + +var _ Screen = (*firstScreen)(nil) + +func (s *firstScreen) String() string { + return "first screen" +} + +func (s *firstScreen) CurrentPageMatches(_ context.Context) bool { + return true +} + +func (s *firstScreen) Do(ctx context.Context) error { + if s.URL == "" { + return &MissingOptionError{Option: "WithURL"} + } + + if err := chromedp.Navigate(s.URL).Do(ctx); err != nil { + return fmt.Errorf("navigate: %w", err) + } + + return nil +} + +func (s *firstScreen) ShouldWaitForResponse() bool { + return true +} + +/* +func injectCookies(currentURL *url.URL, cookies []*http.Cookie) chromedp.Action { + var injectCookies chromedp.Tasks + + for _, cookie := range cookies { + injectCookies = append(injectCookies, chromeCookie(currentURL, cookie)) + } + + return injectCookies +} + +func chromeCookie(u *url.URL, cookie *http.Cookie) *network.SetCookieParams { + expire := cdp.TimeSinceEpoch(cookie.Expires) + + var sameSite network.CookieSameSite + + switch cookie.SameSite { + case http.SameSiteLaxMode: + sameSite = network.CookieSameSiteLax + case http.SameSiteStrictMode: + sameSite = network.CookieSameSiteStrict + case http.SameSiteNoneMode, http.SameSiteDefaultMode: + sameSite = network.CookieSameSiteNone + } + + return network.SetCookie(cookie.Name, cookie.Value). + WithDomain(u.Hostname()). + WithPath(u.Path). + WithURL(u.String()). + WithExpires(&expire). + WithHTTPOnly(cookie.HttpOnly). + WithSecure(cookie.Secure). + WithSameSite(sameSite) +} +*/ diff --git a/login/chrome/screen_otp.go b/login/chrome/screen_otp.go new file mode 100644 index 0000000..a245680 --- /dev/null +++ b/login/chrome/screen_otp.go @@ -0,0 +1,105 @@ +package chrome + +import ( + "context" + "fmt" + "time" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +type otpScreen struct { + Secret string +} + +var _ Screen = (*otpScreen)(nil) + +func (s *otpScreen) String() string { + return "OTP screen" +} + +func (s *otpScreen) CurrentPageMatches(ctx context.Context) bool { + var nodeIDs []cdp.NodeID + + if err := chromedp.Run(ctx, + chromedp.NodeIDs(`#otpCode`, &nodeIDs, chromedp.ByID, chromedp.AtLeast(0)), + ); err != nil { + errorLogger(ctx).Printf("run: %v\n", err) + + return false + } + + return len(nodeIDs) > 0 +} + +var errEmptyOTP = fmt.Errorf("empty OTP secret") + +func (s *otpScreen) Do(ctx context.Context) error { + if s.Secret == "" { + return errEmptyOTP + } + + otpKey, err := otp.NewKeyFromURL(s.Secret) + if err != nil { + return fmt.Errorf("parse secret: %w", err) + } + + otpCode, err := totp.GenerateCode(otpKey.Secret(), time.Now()) + if err != nil { + return fmt.Errorf("generate code: %w", err) + } + + if err := (&chromedp.Tasks{ + // OTP not enabled, skip the screen + chromedp.QueryAfter(`#linkLater`, func(ctx context.Context, eci runtime.ExecutionContextID, n ...*cdp.Node) error { + if len(n) == 0 { + return nil + } + + if err := chromedp.MouseClickNode(n[0]).Do(ctx); err != nil { + return fmt.Errorf("click: %w", err) + } + + return nil + }, chromedp.ByID, chromedp.AtLeast(0)), + + chromedp.WaitVisible(`#otpCode`, chromedp.ByID), + chromedp.WaitEnabled(`#otpCode`, chromedp.ByID), + chromedp.Clear(`#otpCode`, chromedp.ByID), + chromedp.SendKeys(`#otpCode`, otpCode, chromedp.ByID), + + chromedp.WaitVisible(`#submit`, chromedp.ByID), + chromedp.WaitEnabled(`#submit`, chromedp.ByID), + chromedp.Click(`#submit`, chromedp.ByID), + }).Do(ctx); err != nil { + return fmt.Errorf("tasks: %w", err) + } + + return nil +} + +func (s *otpScreen) ShouldWaitForResponse() bool { + return true +} + +/* +func (c *chromeLogin) handleOTPError(ctx context.Context) error { + var nodes []*cdp.Node + + if err := chromedp.Run(ctx, + chromedp.Nodes(`.otp-error`, &nodes, chromedp.ByQuery), + ); err != nil { + return fmt.Errorf("fetch: %w", err) + } + + if len(nodes) == 0 { + return nil + } + + errors.New(nodes[0].Children[0].NodeValue) +} +*/ diff --git a/login/chrome/screen_privacy.go b/login/chrome/screen_privacy.go new file mode 100644 index 0000000..865c8f4 --- /dev/null +++ b/login/chrome/screen_privacy.go @@ -0,0 +1,51 @@ +package chrome + +import ( + "context" + "fmt" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/chromedp" +) + +type privacyScreen struct { + AcceptCookies bool +} + +var _ Screen = (*privacyScreen)(nil) + +func (s *privacyScreen) String() string { + return "privacy screen" +} + +func (s *privacyScreen) CurrentPageMatches(ctx context.Context) bool { + var nodeIDs []cdp.NodeID + + if err := chromedp.Run(ctx, + chromedp.NodeIDs(`#footer_tc_privacy_button_3`, &nodeIDs, chromedp.ByID, chromedp.AtLeast(0)), + ); err != nil { + errorLogger(ctx).Printf("run: %v\n", err) + + return false + } + + return len(nodeIDs) > 0 +} + +func (s *privacyScreen) Do(ctx context.Context) error { + sel := `#footer_tc_privacy_button_3` + + if s.AcceptCookies { + sel = `#footer_tc_privacy_button_2` + } + + if err := chromedp.Click(sel, chromedp.ByID, chromedp.AtLeast(0)).Do(ctx); err != nil { + return fmt.Errorf("click: %w", err) + } + + return nil +} + +func (s *privacyScreen) ShouldWaitForResponse() bool { + return false +} diff --git a/login/chrome/screen_trusted_device.go b/login/chrome/screen_trusted_device.go new file mode 100644 index 0000000..2cf64ca --- /dev/null +++ b/login/chrome/screen_trusted_device.go @@ -0,0 +1,47 @@ +package chrome + +import ( + "context" + "fmt" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/chromedp" +) + +type trustedDeviceScreen struct{} + +var _ Screen = (*trustedDeviceScreen)(nil) + +func (s *trustedDeviceScreen) String() string { + return "trusted device screen" +} + +func (s *trustedDeviceScreen) CurrentPageMatches(ctx context.Context) bool { + var nodeIDs []cdp.NodeID + + err := chromedp.Run(ctx, + chromedp.NodeIDs(`#save-trusted-device-form`, &nodeIDs, chromedp.ByID, chromedp.AtLeast(0)), + ) + if err != nil { + errorLogger(ctx).Printf("run: %v\n", err) + + return false + } + + return len(nodeIDs) > 0 +} + +func (s *trustedDeviceScreen) Do(ctx context.Context) error { + if err := (&chromedp.Tasks{ + chromedp.WaitVisible(`#linkLater`, chromedp.BySearch), + chromedp.Click(`#linkLater`, chromedp.ByID), + }).Do(ctx); err != nil { + return fmt.Errorf("tasks: %w", err) + } + + return nil +} + +func (s *trustedDeviceScreen) ShouldWaitForResponse() bool { + return true +} diff --git a/login/chrome/screens.go b/login/chrome/screens.go new file mode 100644 index 0000000..0dd1d18 --- /dev/null +++ b/login/chrome/screens.go @@ -0,0 +1,122 @@ +package chrome + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/chromedp/chromedp" +) + +type Screen interface { + fmt.Stringer + CurrentPageMatches(ctx context.Context) bool + ShouldWaitForResponse() bool + chromedp.Action +} + +type Screens struct { + screens []Screen + refreshFrequency time.Duration + + succeeded atomic.Bool +} + +func (s *Screens) Resolve(ctx context.Context) { + var waitGroup sync.WaitGroup + + infoLgr, errorLgr := infoLogger(ctx), errorLogger(ctx) + infoPrefix, errorPrefix := infoLgr.Prefix(), errorLgr.Prefix() + + defer infoLgr.Println("Stopped all resolvers") + + for _, screen := range s.screens { + infoLgr.SetPrefix(fmt.Sprintf("%s[%v] ", infoPrefix, screen)) + errorLgr.SetPrefix(fmt.Sprintf("%s[%v] ", errorPrefix, screen)) + + ctx := withErrorLogger(withInfoLogger(ctx, infoLgr), errorLgr) + + waitGroup.Add(1) + + go func(ctx context.Context, screen Screen) { + defer waitGroup.Done() + + s.run(ctx, screen) + }(ctx, screen) + } + + waitGroup.Wait() +} + +func (s *Screens) run(ctx context.Context, screen Screen) { + refreshFrequency := time.NewTicker(s.refreshFrequency) + defer refreshFrequency.Stop() + + infoLogger(ctx).Println("Started resolver...") + defer infoLogger(ctx).Println("Stopped resolver") + + for !s.succeeded.Load() { + select { + case <-ctx.Done(): + return + + case <-refreshFrequency.C: + if !screen.CurrentPageMatches(ctx) { + continue + } + + ctx, cancel := context.WithTimeout(ctx, s.refreshFrequency) + + infoLogger(ctx).Println("Resolving screen...") + + err := resolve(ctx, screen) + + cancel() + + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + infoLogger(ctx).Printf("Screen failed: %v\n", err) + + continue + } + + errorLogger(ctx).Printf("Failed to run in chrome: %v\n", err) + + continue + } + + infoLogger(ctx).Println("Screen passed") + } + } +} + +func resolve(ctx context.Context, screen Screen) error { + if screen.ShouldWaitForResponse() { + resp, err := chromedp.RunResponse(ctx, screen) + if err != nil { + return fmt.Errorf("run response: %w", err) + } + + if resp != nil && resp.Status >= 400 { + return fmt.Errorf("response: %w", &HTTPError{ + Status: resp.Status, + StatusText: resp.StatusText, + }) + } + + return nil + } + + if err := chromedp.Run(ctx, screen); err != nil { + return fmt.Errorf("run: %w", err) + } + + return nil +} + +func (s *Screens) Succeeded() bool { + return s.succeeded.Load() +} diff --git a/login/chrome/screenshot.go b/login/chrome/screenshot.go new file mode 100644 index 0000000..98fbcf4 --- /dev/null +++ b/login/chrome/screenshot.go @@ -0,0 +1,41 @@ +package chrome + +import ( + "context" + "errors" + "image/jpeg" + + "github.com/chromedp/chromedp" +) + +func (c *chromeLogin) ScreenshotIfNeeded(ctx context.Context, errPtr *error) { + if c.screenShortOnError && *errPtr != nil { + *errPtr = c.wrapWithScreenshot(ctx, *errPtr) + } +} + +func (c *chromeLogin) wrapWithScreenshot(ctx context.Context, rootErr error) error { + var imageData []byte + + if err := chromedp.Run(ctx, chromedp.FullScreenshot(&imageData, jpeg.DefaultQuality)); err != nil { + errorLogger(ctx).Printf("Failed to take screenshot: %v\n", err) + + return rootErr + } + + infoLogger(ctx).Println("Screenshot taken") + + return &WithScreenshotError{ + Err: rootErr, + Screenshot: imageData, + } +} + +func GetScreenShot(err error) ([]byte, bool) { + var targetErr *WithScreenshotError + if errors.As(err, &targetErr) { + return targetErr.Screenshot, true + } + + return nil, false +} diff --git a/login/chrome/utils_test.go b/login/chrome/utils_test.go new file mode 100644 index 0000000..df85928 --- /dev/null +++ b/login/chrome/utils_test.go @@ -0,0 +1,23 @@ +package chrome //nolint:testpackage + +import "fmt" + +func (e *WithScreenshotError) GomegaString() string { + return fmt.Sprintf("screenshot: %v", byteCountSI(len(e.Screenshot))) +} + +func byteCountSI(byteCount int) string { + const unit = 1000 + if byteCount < unit { + return fmt.Sprintf("%d B", byteCount) + } + + div, exp := int64(unit), 0 + for n := byteCount / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.1f %cB", + float64(byteCount)/float64(div), "kMGTPE"[exp]) +} diff --git a/login/config/config.go b/login/config/config.go new file mode 100644 index 0000000..af04267 --- /dev/null +++ b/login/config/config.go @@ -0,0 +1,124 @@ +package digiconfig + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/holyhope/digiposte-go-sdk/settings" +) + +const ( + APIURLKey = "api_url" // Configuration key for API URL + DocumentURLKey = "document_url" // Configuration key for document URL + UsernameKey = "username" // Configuration key for username + PasswordKey = "password" // Configuration key for password + OTPSecretKey = "otp" // Configuration key for otp + CookiesKey = "cookies" // Configuration key for cookie +) + +var ( + MustReveal = func(s string) string { return s } //nolint:gochecknoglobals + MustObscure = func(s string) string { return s } //nolint:gochecknoglobals +) + +func DocumentURL(m Getter) string { + val, ok := m.Get(DocumentURLKey) + if !ok { + return settings.DefaultDocumentURL + } + + return val +} + +func SetDocumentURL(m Setter, documentURL string) { + m.Set(DocumentURLKey, documentURL) +} + +func APIURL(m Getter) string { + val, ok := m.Get(APIURLKey) + if !ok { + return settings.DefaultAPIURL + } + + return val +} + +func SetAPIURL(m Setter, apiURL string) { + m.Set(APIURLKey, apiURL) +} + +func Username(m Getter) string { + val, _ := m.Get(UsernameKey) + + return val +} + +func SetUsername(m Setter, username string) { + m.Set(UsernameKey, username) +} + +func Password(m Getter) string { + val, _ := m.Get(PasswordKey) + + return MustReveal(val) +} + +func SetPassword(m Setter, password string) { + m.Set(PasswordKey, MustObscure(password)) +} + +func OTPSecret(m Getter) string { + val, _ := m.Get(OTPSecretKey) + + return MustReveal(val) +} + +func SetOTPSecret(m Setter, otpSecret string) { + m.Set(OTPSecretKey, MustObscure(otpSecret)) +} + +func Cookies(m Getter) []*http.Cookie { + val, ok := m.Get(CookiesKey) + if !ok { + return nil + } + + var cypheredCookies []*http.Cookie + if err := json.Unmarshal([]byte(val), &cypheredCookies); err != nil { + panic(fmt.Errorf("unmarshal cookies: %w", err)) + } + + cookies := make([]*http.Cookie, 0, len(cypheredCookies)) + + for _, cookie := range cypheredCookies { + if err := cookie.Valid(); err != nil { + panic(fmt.Errorf("invalid cookie %q: %w", cookie.Name, err)) + } + + cookie := *cookie + cookie.Value = MustReveal(cookie.Value) + cookies = append(cookies, &cookie) + } + + return cookies +} + +func SetCookies(setter Setter, cookies []*http.Cookie) error { + cypheredCookies := make([]*http.Cookie, 0, len(cookies)) + + for _, cookie := range cookies { + cookie := *cookie + cookie.Value = MustObscure(cookie.Value) + cypheredCookies = append(cypheredCookies, &cookie) + } + + cookiesBytes, err := json.Marshal(cypheredCookies) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + setter.Set(CookiesKey, string(cookiesBytes)) + + return nil +} diff --git a/login/config/configfakes/fake_getter.go b/login/config/configfakes/fake_getter.go new file mode 100644 index 0000000..fe32f04 --- /dev/null +++ b/login/config/configfakes/fake_getter.go @@ -0,0 +1,116 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package configfakes + +import ( + "sync" + + digiconfig "github.com/holyhope/digiposte-go-sdk/login/config" +) + +type FakeGetter struct { + GetStub func(string) (string, bool) + getMutex sync.RWMutex + getArgsForCall []struct { + arg1 string + } + getReturns struct { + result1 string + result2 bool + } + getReturnsOnCall map[int]struct { + result1 string + result2 bool + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeGetter) Get(arg1 string) (string, bool) { + fake.getMutex.Lock() + ret, specificReturn := fake.getReturnsOnCall[len(fake.getArgsForCall)] + fake.getArgsForCall = append(fake.getArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.GetStub + fakeReturns := fake.getReturns + fake.recordInvocation("Get", []interface{}{arg1}) + fake.getMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeGetter) GetCallCount() int { + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + return len(fake.getArgsForCall) +} + +func (fake *FakeGetter) GetCalls(stub func(string) (string, bool)) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = stub +} + +func (fake *FakeGetter) GetArgsForCall(i int) string { + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + argsForCall := fake.getArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeGetter) GetReturns(result1 string, result2 bool) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = nil + fake.getReturns = struct { + result1 string + result2 bool + }{result1, result2} +} + +func (fake *FakeGetter) GetReturnsOnCall(i int, result1 string, result2 bool) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = nil + if fake.getReturnsOnCall == nil { + fake.getReturnsOnCall = make(map[int]struct { + result1 string + result2 bool + }) + } + fake.getReturnsOnCall[i] = struct { + result1 string + result2 bool + }{result1, result2} +} + +func (fake *FakeGetter) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeGetter) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ digiconfig.Getter = new(FakeGetter) diff --git a/login/config/configfakes/fake_setter.go b/login/config/configfakes/fake_setter.go new file mode 100644 index 0000000..c575e5d --- /dev/null +++ b/login/config/configfakes/fake_setter.go @@ -0,0 +1,78 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package configfakes + +import ( + "sync" + + digiconfig "github.com/holyhope/digiposte-go-sdk/login/config" +) + +type FakeSetter struct { + SetStub func(string, string) + setMutex sync.RWMutex + setArgsForCall []struct { + arg1 string + arg2 string + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeSetter) Set(arg1 string, arg2 string) { + fake.setMutex.Lock() + fake.setArgsForCall = append(fake.setArgsForCall, struct { + arg1 string + arg2 string + }{arg1, arg2}) + stub := fake.SetStub + fake.recordInvocation("Set", []interface{}{arg1, arg2}) + fake.setMutex.Unlock() + if stub != nil { + fake.SetStub(arg1, arg2) + } +} + +func (fake *FakeSetter) SetCallCount() int { + fake.setMutex.RLock() + defer fake.setMutex.RUnlock() + return len(fake.setArgsForCall) +} + +func (fake *FakeSetter) SetCalls(stub func(string, string)) { + fake.setMutex.Lock() + defer fake.setMutex.Unlock() + fake.SetStub = stub +} + +func (fake *FakeSetter) SetArgsForCall(i int) (string, string) { + fake.setMutex.RLock() + defer fake.setMutex.RUnlock() + argsForCall := fake.setArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSetter) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.setMutex.RLock() + defer fake.setMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeSetter) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ digiconfig.Setter = new(FakeSetter) diff --git a/login/config/mapper.go b/login/config/mapper.go new file mode 100644 index 0000000..69975c0 --- /dev/null +++ b/login/config/mapper.go @@ -0,0 +1,33 @@ +package digiconfig + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate + +// Setter provides an interface to set config items +// +//counterfeiter:generate . Setter +type Setter interface { + // Set should set an item into persistent config store. + Set(key, value string) +} + +type SetterFunc func(key, value string) + +func (f SetterFunc) Set(key, value string) { + f(key, value) +} + +// Getter provides an interface to get config items +// +//counterfeiter:generate . Getter +type Getter interface { + // Get should get an item with the key passed in and return + // the value. If the item is found then it should return true, + // otherwise false. + Get(key string) (value string, ok bool) +} + +type GetterFunc func(key string) (value string, ok bool) + +func (f GetterFunc) Get(key string) (string, bool) { + return f(key) +} diff --git a/login/config/tools.go b/login/config/tools.go new file mode 100644 index 0000000..52be392 --- /dev/null +++ b/login/config/tools.go @@ -0,0 +1,7 @@ +//go:build tools + +package login + +import ( + _ "github.com/maxbrunsfeld/counterfeiter/v6" +) diff --git a/login/server.go b/login/server.go new file mode 100644 index 0000000..3b12f01 --- /dev/null +++ b/login/server.go @@ -0,0 +1,192 @@ +package login + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "sync" + "time" + + "github.com/go-oauth2/oauth2/v4" + oautherrs "github.com/go-oauth2/oauth2/v4/errors" + "github.com/go-oauth2/oauth2/v4/manage" + "github.com/go-oauth2/oauth2/v4/models" + "github.com/go-oauth2/oauth2/v4/server" + "github.com/go-oauth2/oauth2/v4/store" + + digiconfig "github.com/holyhope/digiposte-go-sdk/login/config" +) + +const ( + // AuthorizePath is the path to the authorize endpoint. + AuthorizePath = "/authorize" + // TokenPath is the path to the token endpoint. + TokenPath = "/token" + + // ReadTimeout is the timeout for reading the request. + ReadTimeout = 5 * time.Second + // WriteTimeout is the timeout for writing the response. + WriteTimeout = 5 * time.Minute +) + +// Server is a local web server for collecting auth. +type Server struct { + server *http.Server + listener net.Listener + manager *manage.Manager + clientStore *store.ClientStore + accessGenerator *AccessGenerator +} + +type Config struct { + Addr string + Server *server.Config + LoginMethod Method + Logger *log.Logger +} + +// StartServer starts a local webserver to receive the auth. +func NewServer(setter digiconfig.Setter, config *Config) (*Server, error) { + // client memory store + clientStore := store.NewClientStore() + + accessGenerator := &AccessGenerator{ + setter: setter, + loginMethod: config.LoginMethod, + credentials: &sync.Map{}, + } + + manager := newManager(clientStore, accessGenerator) + + listener, err := net.Listen("tcp", config.Addr) + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + return &Server{ + server: newServer(manager, config.Server, listener, config.Logger), + listener: listener, + manager: manager, + clientStore: clientStore, + accessGenerator: accessGenerator, + }, nil +} + +// RegisterUser adds a user to the server. +func (s *Server) RegisterUser(clientID, clientSecret, redirectURL, username, password, otpSecret string) error { + if err := s.clientStore.Set(clientID, &models.Client{ + ID: clientID, + Secret: clientSecret, + UserID: clientID, + Public: false, + Domain: redirectURL, + }); err != nil { + return fmt.Errorf("set client: %w", err) + } + + s.accessGenerator.SetCredentials(clientID, &Credentials{ + Username: username, + Password: password, + OTPSecret: otpSecret, + }) + + return nil +} + +func newServer(manager oauth2.Manager, config *server.Config, listener net.Listener, logger *log.Logger) *http.Server { + mux := http.NewServeMux() + oauthServer := server.NewServer(config, manager) + + mux.HandleFunc(AuthorizePath, func(w http.ResponseWriter, r *http.Request) { + if err := oauthServer.HandleAuthorizeRequest(w, r); err != nil { + logger.Printf("Failed to handle authorize request: %v", err) + } + }) + mux.HandleFunc(TokenPath, func(w http.ResponseWriter, r *http.Request) { + if err := oauthServer.HandleTokenRequest(w, r); err != nil { + logger.Printf("Failed to handle token request: %v", err) + } + }) + + oauthServer.SetAllowGetAccessRequest(true) + + oauthServer.UserAuthorizationHandler = func(w http.ResponseWriter, r *http.Request) (string, error) { + if err := r.ParseForm(); err != nil { + return "", oautherrs.ErrInvalidRequest + } + + return r.Form.Get("client_id"), nil + } + + oauthServer.ClientInfoHandler = func(r *http.Request) (string, string, error) { + if err := r.ParseForm(); err != nil { + return "", "", oautherrs.ErrInvalidRequest + } + + return r.Form.Get("client_id"), r.Form.Get("client_secret"), nil + } + + httpServer := &http.Server{ + Addr: listener.Addr().String(), + ErrorLog: logger, + Handler: mux, + ReadHeaderTimeout: ReadTimeout, + ReadTimeout: ReadTimeout, + WriteTimeout: WriteTimeout, + IdleTimeout: WriteTimeout, + + TLSConfig: nil, + MaxHeaderBytes: 0, + BaseContext: nil, + TLSNextProto: nil, + ConnState: nil, + ConnContext: nil, + } + + return httpServer +} + +func newManager(cs oauth2.ClientStore, ag oauth2.AccessGenerate) *manage.Manager { + manager := manage.NewDefaultManager() + + manager.MustTokenStorage(store.NewMemoryTokenStore()) + manager.MapClientStorage(cs) + manager.MapAccessGenerate(ag) + manager.SetRefreshTokenCfg(&manage.RefreshingConfig{ + AccessTokenExp: time.Hour, + IsGenerateRefresh: true, + RefreshTokenExp: 0, + IsResetRefreshTime: false, + IsRemoveAccess: false, + IsRemoveRefreshing: true, + }) + + return manager +} + +// Close closes the server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) //nolint:wrapcheck +} + +// Start starts the server. +func (s *Server) Start() error { + if err := s.server.Serve(s.listener); !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve: %w", err) + } + + return nil +} + +// AuthorizeURL returns the URL to the authorize endpoint. +func (s *Server) AuthorizeURL() string { + return "http://" + s.listener.Addr().String() + AuthorizePath +} + +// TokenURL returns the URL to the token endpoint. +func (s *Server) TokenURL() string { + return "http://" + s.listener.Addr().String() + TokenPath +} diff --git a/login/server_test.go b/login/server_test.go new file mode 100644 index 0000000..1a1f646 --- /dev/null +++ b/login/server_test.go @@ -0,0 +1,163 @@ +package login_test + +import ( + "context" + "errors" + "log" + "net/http" + "time" + + "github.com/go-oauth2/oauth2/v4/server" + "github.com/onsi/gomega/ghttp" + "golang.org/x/oauth2" + + login "github.com/holyhope/digiposte-go-sdk/login" + digiconfig "github.com/holyhope/digiposte-go-sdk/login/config" + configfakes "github.com/holyhope/digiposte-go-sdk/login/config/configfakes" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive +) + +const ( + ClientID = "client-id" + ClientSecret = "client-secret" + Username = "username" + Password = "password" + OTPSecret = "otp-secret" +) + +var _ = Describe("Server", func() { + var ( + setter *configfakes.FakeSetter + oauthServer *login.Server + testServer *ghttp.Server + cfg *oauth2.Config + ) + + BeforeEach(func() { + testServer = ghttp.NewServer() + DeferCleanup(testServer.Close) + + setter = &configfakes.FakeSetter{ + SetStub: func(key, value string) { + Expect(key).To(Equal(digiconfig.CookiesKey)) + Expect(value).ToNot(BeEmpty()) + }, + } + + loginMethod := func(ctx context.Context, creds *login.Credentials) (*oauth2.Token, []*http.Cookie, error) { + Expect(creds).To(Equal(&login.Credentials{ + Username: Username, + Password: Password, + OTPSecret: OTPSecret, + })) + + return &oauth2.Token{ + AccessToken: "access-token", + TokenType: "token-type", + RefreshToken: "refresh-token", + Expiry: time.Now().Add(time.Hour), + }, []*http.Cookie{{ + Name: "cookie-name", + Value: "cookie-value", + Path: "/digi-test", + Domain: "digi-test.fr", + }}, nil + } + + localServer, err := login.NewServer( + setter, + &login.Config{ + Addr: ":0", // Random port + Server: server.NewConfig(), + Logger: log.New(GinkgoWriter, "", log.Lmsgprefix), + LoginMethod: login.MethodFunc(loginMethod), + }, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(localServer).ToNot(BeNil()) + + Expect(localServer.RegisterUser( + ClientID, ClientSecret, testServer.URL(), + Username, Password, OTPSecret, + )).To(Succeed()) + + go func(server *login.Server) { + defer GinkgoRecover() + + Expect(server.Start()).To(Succeed()) + }(localServer) + + DeferCleanup(func() { + Expect(oauthServer.Shutdown(context.Background())).To(Succeed()) + }) + + oauthServer = localServer + + cfg = &oauth2.Config{ + ClientID: ClientID, + ClientSecret: ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: oauthServer.AuthorizeURL(), + TokenURL: oauthServer.TokenURL(), + AuthStyle: oauth2.AuthStyleInParams, + DeviceAuthURL: "", + }, + RedirectURL: testServer.URL(), + Scopes: nil, + } + }) + + Context("When using a password", func() { + It("Should fail with a bad password", func() { + _, err := cfg.PasswordCredentialsToken(context.Background(), "username", "password") + Expect(err).To(HaveOccurred()) + + var targetErr *oauth2.RetrieveError + + if errors.As(err, &targetErr) { + Expect(targetErr.Response.StatusCode).To(Equal(http.StatusForbidden)) + } + }) + }) + + It("Should be able to generate a token", func() { + var code string + + req, err := http.NewRequest(http.MethodGet, cfg.AuthCodeURL("tests"), nil) + Expect(err).ToNot(HaveOccurred()) + + initialRequest := req.Clone(context.Background()) + + testServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/"), + func(writer http.ResponseWriter, req *http.Request) { + Expect(req.Header.Get("Referer")).To(Equal(initialRequest.URL.String())) + + query := req.URL.Query() + Expect(query.Get("state")).To(Equal("tests")) + + code = query.Get("code") + + writer.WriteHeader(http.StatusNoContent) + }, + )) + + resp, err := http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) + + Expect(code).ToNot(BeEmpty()) + + Expect(setter.Invocations()).To(BeEmpty()) + + token, err := cfg.Exchange(context.Background(), code) + Expect(err).ToNot(HaveOccurred()) + Expect(token.Valid()).To(BeTrue()) + + Expect(setter.Invocations()).To(HaveKeyWithValue("Set", ConsistOf( + ConsistOf(Equal(digiconfig.CookiesKey), Not(BeEmpty())), + ))) + }) +}) diff --git a/login/suite_test.go b/login/suite_test.go new file mode 100644 index 0000000..e4f1c38 --- /dev/null +++ b/login/suite_test.go @@ -0,0 +1,15 @@ +package login_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive +) + +func TestRcloneDigiposteLogin(t *testing.T) { + t.Parallel() + + RegisterFailHandler(Fail) + RunSpecs(t, "Suite") +} diff --git a/login/types.go b/login/types.go new file mode 100644 index 0000000..31ea0f8 --- /dev/null +++ b/login/types.go @@ -0,0 +1,53 @@ +package login + +import ( + "context" + "fmt" + "net/http" + + "golang.org/x/oauth2" +) + +type Credentials struct { + Username string + Password string + OTPSecret string +} + +type Option interface { + Apply(instance interface{}) error +} + +type OptionFunc func(instance interface{}) error + +func (f OptionFunc) Apply(instance interface{}) error { + return f(instance) +} + +// Method is the method to connect to digiposte. +type Method interface { + Login(ctx context.Context, creds *Credentials) (*oauth2.Token, []*http.Cookie, error) +} + +type MethodFunc func(ctx context.Context, creds *Credentials) (*oauth2.Token, []*http.Cookie, error) + +func (f MethodFunc) Login(ctx context.Context, creds *Credentials) (*oauth2.Token, []*http.Cookie, error) { + return f(ctx, creds) +} + +type InvalidOptionError struct { + Name string + Err error +} + +func (e *InvalidOptionError) Error() string { + return fmt.Sprintf("option %q: %v", e.Name, e.Err) +} + +func (e *InvalidOptionError) Unwrap() error { + return e.Err +} + +func (e *InvalidOptionError) Apply(interface{}) error { + return e +} diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..25a4900 --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,9 @@ +package settings + +const ( + DefaultAPIURL = "https://api.digiposte.fr/api" + DefaultDocumentURL = "https://secure.digiposte.fr" + + StagingAPIURL = "https://api.interop.digiposte.io/api" + StagingDocumentURL = "https://secure.interop.digiposte.io" +) diff --git a/v1/client.go b/v1/client.go index 4f10e87..5943519 100644 --- a/v1/client.go +++ b/v1/client.go @@ -7,16 +7,14 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "strings" -) -const ( - DefaultAPIURL = "https://api.digiposte.fr/api" - DefaultDocumentURL = "https://secure.digiposte.fr" + "golang.org/x/oauth2" - StagingAPIURL = "https://api.interop.digiposte.io/api" - StagingDocumentURL = "https://secure.interop.digiposte.io" + login "github.com/holyhope/digiposte-go-sdk/login" + "github.com/holyhope/digiposte-go-sdk/settings" ) // Client is a Digiposte client. @@ -28,7 +26,44 @@ type Client struct { // NewClient creates a new Digiposte client. func NewClient(client *http.Client) *Client { - return NewCustomClient(DefaultAPIURL, DefaultDocumentURL, client) + return NewCustomClient(settings.DefaultAPIURL, settings.DefaultDocumentURL, client) +} + +type Config struct { + APIURL string + DocumentURL string + + LoginMethod login.Method + Credentials *login.Credentials +} + +// NewAuthenticatedClient creates a new Digiposte client with the given credentials. +func NewAuthenticatedClient(ctx context.Context, client *http.Client, config *Config) (*Client, error) { + token, cookies, err := config.LoginMethod.Login(ctx, config.Credentials) + if err != nil { + return nil, fmt.Errorf("login: %w", err) + } + + documentURL, err := url.Parse(config.DocumentURL) + if err != nil { + return nil, fmt.Errorf("parse document URL: %w", err) + } + + if client.Jar == nil { + client.Jar, err = cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("new cookie jar: %w", err) + } + } + + client.Jar.SetCookies(documentURL, cookies) + + client.Transport = &oauth2.Transport{ + Base: client.Transport, + Source: oauth2.StaticTokenSource(token), + } + + return NewCustomClient(config.APIURL, config.DocumentURL, client), nil } // NewClient creates a new Digiposte client. @@ -40,14 +75,24 @@ func NewCustomClient(apiURL, documentURL string, client *http.Client) *Client { } } +const JSONContentType = "application/json" + func (c *Client) apiRequest(ctx context.Context, method string, path string, body io.Reader) (*http.Request, error) { - return http.NewRequestWithContext(ctx, method, c.apiURL+path, body) //nolint:wrapcheck + req, err := http.NewRequestWithContext(ctx, method, c.apiURL+path, body) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + + req.Header.Set("Accept", JSONContentType) + req.Header.Set("Content-Type", JSONContentType) + + return req, nil } const TrashDirName = "trash" // ID represents an internal digiposte ID. -type ID string +type digiposteID string // CloseBodyError is an error returned when the body of a response cannot be closed. type CloseBodyError struct { @@ -68,43 +113,56 @@ func (e *CloseBodyError) Unwrap() error { } // RequestError is an error returned when the API returns an error. -type RequestError struct { +type RequestErrors []struct { ErrorCode string `json:"error"` ErrorDesc string `json:"error_description,omitempty"` Context map[string]interface{} `json:"context,omitempty"` } -func (e *RequestError) Error() string { - return fmt.Sprintf("%s (%s)", e.ErrorDesc, e.ErrorCode) +func (e *RequestErrors) Error() string { + strs := make([]string, 0, len(*e)) + + for _, err := range *e { + strs = append(strs, fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDesc)) + } + + return strings.Join(strs, "\n") } func (c *Client) checkResponse(response *http.Response, expectedStatus int) error { if response.StatusCode != expectedStatus { - var typedError RequestError + errs := new(RequestErrors) content, err := io.ReadAll(response.Body) if err != nil { return fmt.Errorf("HTTP %s: failed to read response body: %w", response.Status, err) } - if err := json.Unmarshal(content, &typedError); err != nil { - return &RequestError{ - ErrorCode: response.Status, - ErrorDesc: string(content), - Context: map[string]interface{}{ - "content-type": response.Header.Get("Content-Type"), - }, + if err := json.Unmarshal(content, errs); err != nil { + context := map[string]interface{}{ + "content": content, + "decode_error": err, } + + if contentType := response.Header.Get("Content-Type"); contentType != "" { + context["content-type"] = contentType + } + + return &RequestErrors{{ + ErrorCode: response.Status, + ErrorDesc: "failed to decode error response", + Context: context, + }} } - return fmt.Errorf("%s: %w", response.Status, &typedError) + return fmt.Errorf("HTTP %s: %w", response.Status, errs) } return nil } // Trash move trashes the given documents and folders to the trash. -func (c *Client) Trash(ctx context.Context, documentIDs, folderIDs []ID) error { +func (c *Client) Trash(ctx context.Context, documentIDs []DocumentID, folderIDs []FolderID) error { body, err := json.Marshal(map[string]interface{}{ "document_ids": documentIDs, "folder_ids": folderIDs, @@ -122,13 +180,11 @@ func (c *Client) Trash(ctx context.Context, documentIDs, folderIDs []ID) error { queryParams.Set("check", "false") req.URL.RawQuery = queryParams.Encode() - req.Header.Set("Content-Type", "application/json") - return c.call(req, nil) } // Delete deletes permanently the given documents and folders. -func (c *Client) Delete(ctx context.Context, documentIDs, folderIDs []ID) error { +func (c *Client) Delete(ctx context.Context, documentIDs []DocumentID, folderIDs []FolderID) error { body, err := json.Marshal(map[string]interface{}{ "document_ids": documentIDs, "folder_ids": folderIDs, @@ -146,7 +202,7 @@ func (c *Client) Delete(ctx context.Context, documentIDs, folderIDs []ID) error } // Move moves the given documents and folders to the given destination. -func (c *Client) Move(ctx context.Context, destinationID ID, documentIDs, folderIDs []ID) error { +func (c *Client) Move(ctx context.Context, destID FolderID, documentIDs []DocumentID, folderIDs []FolderID) error { body, err := json.Marshal(map[string]interface{}{ "document_ids": documentIDs, "folder_ids": folderIDs, @@ -155,15 +211,13 @@ func (c *Client) Move(ctx context.Context, destinationID ID, documentIDs, folder return fmt.Errorf("marshal body: %w", err) } - endpoint := "/v3/file/tree/move?to=" + url.QueryEscape(string(destinationID)) + endpoint := "/v3/file/tree/move?to=" + url.QueryEscape(string(destID)) req, err := c.apiRequest(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("new request: %w", err) } - req.Header.Set("Content-Type", "application/json") - return c.call(req, nil) } @@ -185,7 +239,11 @@ func (c *Client) call(req *http.Request, result interface{}) (finalErr error) { } if err := c.checkResponse(response, expectedStatus); err != nil { - return fmt.Errorf("request to %q: %w", req.URL, err) + return fmt.Errorf("%s to %q: %w", req.Method, req.URL, err) + } + + if result == nil { + return nil } if err := json.NewDecoder(response.Body).Decode(result); err != nil { diff --git a/v1/document.go b/v1/document.go index 6672043..383b8fc 100644 --- a/v1/document.go +++ b/v1/document.go @@ -13,6 +13,8 @@ import ( "time" ) +type DocumentID digiposteID + // GetTrashedDocuments returns all documents in the trash. func (c *Client) GetTrashedDocuments(ctx context.Context) (*SearchDocumentsResult, error) { body, err := json.Marshal(map[string]interface{}{ @@ -32,8 +34,6 @@ func (c *Client) GetTrashedDocuments(ctx context.Context) (*SearchDocumentsResul queryParams.Set("sort", "TITLE") req.URL.RawQuery = queryParams.Encode() - req.Header.Set("Content-Type", "application/json") - var result SearchDocumentsResult return &result, c.call(req, &result) @@ -41,17 +41,17 @@ func (c *Client) GetTrashedDocuments(ctx context.Context) (*SearchDocumentsResul // Document represents a document. type Document struct { - InternalID ID `json:"id"` - Name string `json:"filename"` - CreatedAt time.Time `json:"creation_date"` - Size int64 `json:"size"` - MimeType string `json:"mimetype"` - FolderID string `json:"folder_id"` - Location string `json:"location"` - Shared bool `json:"shared"` - Read bool `json:"read"` - HealthDocument bool `json:"health_document"` - UserTags []string `json:"user_tags"` + InternalID DocumentID `json:"id"` + Name string `json:"filename"` + CreatedAt time.Time `json:"creation_date"` + Size int64 `json:"size"` + MimeType string `json:"mimetype"` + FolderID string `json:"folder_id"` + Location string `json:"location"` + Shared bool `json:"shared"` + Read bool `json:"read"` + HealthDocument bool `json:"health_document"` + UserTags []string `json:"user_tags"` } // ListDocuments returns all documents at the root. @@ -67,7 +67,7 @@ func (c *Client) ListDocuments(ctx context.Context) (*SearchDocumentsResult, err } // DocumentContent returns the content of a document. -func (c *Client) DocumentContent(ctx context.Context, internalID ID) ( //nolint:nonamedreturns +func (c *Client) DocumentContent(ctx context.Context, internalID DocumentID) ( //nolint:nonamedreturns contentBuffer io.ReadCloser, contentType string, finalErr error, @@ -210,7 +210,7 @@ func OnlyDocumentLocatedAt(locations ...Location) DocumentSearchOption { } // SearchDocuments searches for documents in the given locations. -func (c *Client) SearchDocuments(ctx context.Context, internalID ID, options ...DocumentSearchOption) ( +func (c *Client) SearchDocuments(ctx context.Context, internalID FolderID, options ...DocumentSearchOption) ( *SearchDocumentsResult, error, ) { @@ -238,15 +238,13 @@ func (c *Client) SearchDocuments(ctx context.Context, internalID ID, options ... queryParams.Set("sort", "TITLE") req.URL.RawQuery = queryParams.Encode() - req.Header.Set("Content-Type", "application/json") - var result SearchDocumentsResult return &result, c.call(req, &result) } // RenameDocument renames a document. -func (c *Client) RenameDocument(ctx context.Context, internalID ID, name string) (*Document, error) { +func (c *Client) RenameDocument(ctx context.Context, internalID DocumentID, name string) (*Document, error) { endpoint := "/v3/document/" + url.PathEscape(string(internalID)) + "/rename/" + url.PathEscape(name) req, err := c.apiRequest(ctx, http.MethodPut, endpoint, nil) @@ -260,7 +258,7 @@ func (c *Client) RenameDocument(ctx context.Context, internalID ID, name string) } // CopyDocuments copies the given documents in the same folder. -func (c *Client) CopyDocuments(ctx context.Context, documentIDs []ID) (*SearchDocumentsResult, error) { +func (c *Client) CopyDocuments(ctx context.Context, documentIDs []DocumentID) (*SearchDocumentsResult, error) { body, err := json.Marshal(map[string]interface{}{ "documents": documentIDs, }) @@ -273,15 +271,13 @@ func (c *Client) CopyDocuments(ctx context.Context, documentIDs []ID) (*SearchDo return nil, fmt.Errorf("new request: %w", err) } - req.Header.Set("Content-Type", "application/json") - var result SearchDocumentsResult return &result, c.call(req, &result) } // MultiTag adds the given tags to the given documents. -func (c *Client) MultiTag(ctx context.Context, tags map[ID][]string) error { +func (c *Client) MultiTag(ctx context.Context, tags map[DocumentID][]string) error { body, err := json.Marshal(map[string]interface{}{ "tags": tags, }) @@ -294,8 +290,6 @@ func (c *Client) MultiTag(ctx context.Context, tags map[ID][]string) error { return fmt.Errorf("new request: %w", err) } - req.Header.Set("Content-Type", "application/json") - return c.call(req, nil) } @@ -311,90 +305,74 @@ const ( // CreateDocument creates a document. func (c *Client) CreateDocument( //nolint:nonamedreturns ctx context.Context, - folderID ID, + folderID FolderID, name string, data io.Reader, docType DocumentType, ) (document *Document, finalErr error) { var buf bytes.Buffer - formWriter := multipart.NewWriter(&buf) - defer func(formWriter *multipart.Writer) { - if err := formWriter.Close(); err != nil { - if finalErr == nil { - finalErr = &CloseWriterError{Err: err, OriginalError: finalErr} - } - } - }(formWriter) + formWriter, err := uploadForm(&buf, docType, folderID, name, data) + if err != nil { + return nil, fmt.Errorf("upload form: %w", err) + } req, err := c.apiRequest(ctx, http.MethodPost, "/v3/document", &buf) if err != nil { return nil, fmt.Errorf("new request: %w", err) } - // Copy the actual file content to the target destination - if err := populateUploadForm(formWriter, docType, folderID, name, data); err != nil { - return nil, fmt.Errorf("populate form: %w", err) - } - req.Header.Set("Content-Type", formWriter.FormDataContentType()) + req.Header.Set("X-API-VERSION-MINOR", "2") + req.Header.Set("Origin", "https://github.com/holyhope/digiposte-go-sdk") document = new(Document) return document, c.call(req, document) } -func populateUploadForm( //nolint:nonamedreturns - formWriter *multipart.Writer, +func uploadForm( + writer io.Writer, docType DocumentType, - folderID ID, + folderID FolderID, name string, data io.Reader, -) (finalErr error) { +) (*multipart.Writer, error) { + formWriter := multipart.NewWriter(writer) + if err := formWriter.WriteField("health_document", strconv.FormatBool(docType == DocumentTypeHealth)); err != nil { - return fmt.Errorf("write health_document: %w", err) + return formWriter, fmt.Errorf("write health_document: %w", err) } - if err := formWriter.WriteField("folder_id", string(folderID)); err != nil { - return fmt.Errorf("write folder_id: %w", err) + if folderID != "" { + if err := formWriter.WriteField("folder_id", string(folderID)); err != nil { + return formWriter, fmt.Errorf("write folder_id: %w", err) + } } if err := formWriter.WriteField("title", name); err != nil { - return fmt.Errorf("write title: %w", err) + return formWriter, fmt.Errorf("write title: %w", err) } documentUploadStream, err := formWriter.CreateFormFile("archive", name) if err != nil { - return fmt.Errorf("create archive file: %w", err) + return formWriter, fmt.Errorf("create archive file: %w", err) } - sizeStream, err := formWriter.CreateFormField("archive_size") + size, err := io.Copy(documentUploadStream, data) if err != nil { - return fmt.Errorf("create archive_size field: %w", err) + return formWriter, fmt.Errorf("copy archive file: %w", err) } - go func(documentUploadStream, sizeStream io.Writer, content io.Reader) { - if err := upload(documentUploadStream, content, sizeStream); err != nil { - if finalErr == nil { - finalErr = &UploadError{Err: err, OriginalError: finalErr} - } - } - }(documentUploadStream, sizeStream, data) - - return nil -} - -func upload(documentUploadStream io.Writer, content io.Reader, sizeStream io.Writer) error { - size, err := io.Copy(documentUploadStream, content) - if err != nil { - return fmt.Errorf("copy: %w", err) + if err := formWriter.WriteField("archive_size", strconv.FormatInt(size, 10)); err != nil { + return formWriter, fmt.Errorf("write archive_size: %w", err) } - if _, err := sizeStream.Write([]byte(strconv.FormatInt(size, 10))); err != nil { - return fmt.Errorf("write size: %w", err) + if err := formWriter.Close(); err != nil { + return formWriter, fmt.Errorf("close form writer: %w", err) } - return nil + return formWriter, nil } // CloseWriterError represents an error during the closing of a writer. diff --git a/v1/document_test.go b/v1/document_test.go new file mode 100644 index 0000000..7986384 --- /dev/null +++ b/v1/document_test.go @@ -0,0 +1,36 @@ +package digiposte_test + +import ( + "strings" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/holyhope/digiposte-go-sdk/v1" +) + +var _ = ginkgo.Describe("Document", func() { + ginkgo.Describe("CreateDocument", func() { + var document *digiposte.Document + + ginkgo.AfterEach(func(ctx ginkgo.SpecContext) { + gomega.Expect(digiposteClient.Trash(ctx, []digiposte.DocumentID{document.InternalID}, nil)).To(gomega.Succeed()) + gomega.Expect(digiposteClient.Delete(ctx, []digiposte.DocumentID{document.InternalID}, nil)).To(gomega.Succeed()) + }) + + ginkgo.Context("when the document does not exist", func() { + ginkgo.It("should create a document", func(ctx ginkgo.SpecContext) { + var err error + + document, err = digiposteClient.CreateDocument(ctx, + digiposte.RootFolderID, + ginkgo.CurrentSpecReport().FullText(), + strings.NewReader("the content"), + digiposte.DocumentTypeBasic, + ) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(document.InternalID).ToNot(gomega.BeEmpty()) + }) + }) + }) +}) diff --git a/v1/example_test.go b/v1/example_test.go new file mode 100644 index 0000000..ceae8e9 --- /dev/null +++ b/v1/example_test.go @@ -0,0 +1,108 @@ +package digiposte_test + +import ( + "context" + "embed" + "fmt" + "io" + + "github.com/holyhope/digiposte-go-sdk/v1" +) + +//go:embed testdata/document.txt +var testData embed.FS + +// ListFolders returns all folders at the root. +func Example() { //nolint:funlen + ctx := context.Background() + + /* Create a new authenticated HTTP client using the following environment variables: + * - DIGIPOSTE_API + * - DIGIPOSTE_URL + * - DIGIPOSTE_USERNAME + * - DIGIPOSTE_PASSWORD + * - DIGIPOSTE_OTP_SECRET + */ + + client, err := DigiposteClient(ctx) + if err != nil { + panic(fmt.Errorf("new digiposte client: %w", err)) + } + + /* Handle the cleanup of the created folder and document */ + + var ( + folders []digiposte.FolderID + documents []digiposte.DocumentID + ) + + defer func(ctx context.Context) { + if err := client.Trash(ctx, documents, folders); err != nil { + panic(fmt.Errorf("trash: %w", err)) + } + + fmt.Printf("Trashed %d document(s)\n", len(documents)) + + if err := client.Delete(ctx, documents, folders); err != nil { + panic(fmt.Errorf("cleanup: %w", err)) + } + + fmt.Printf("Permanently deleted %d document(s)\n", len(documents)) + }(context.Background()) + + /* Create a folder */ + + folder, err := client.CreateFolder(ctx, digiposte.RootFolderID, "digiposte-go-sdk Example") + if err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + folders = append(folders, folder.InternalID) + + fmt.Printf("Folder %q created\n", folder.Name) + + /* Create a document */ + + document, err := testData.Open("testdata/document.txt") + if err != nil { + panic(fmt.Errorf("open testdata file: %w", err)) + } + + stat, err := document.Stat() + if err != nil { + panic(fmt.Errorf("stat testdata file: %w", err)) + } + + doc, err := client.CreateDocument(ctx, folder.InternalID, stat.Name(), document, digiposte.DocumentTypeBasic) + if err != nil { + panic(fmt.Errorf("create document: %w", err)) + } + + documents = append(documents, doc.InternalID) + + fmt.Printf("Document %q created\n", doc.Name) + + /* Get document content */ + + contentReader, contentType, err := client.DocumentContent(ctx, doc.InternalID) + if err != nil { + panic(fmt.Errorf("get document content: %w", err)) + } + + fmt.Printf("Document content type: %s\n", contentType) + + content, err := io.ReadAll(contentReader) + if err != nil { + panic(fmt.Errorf("read document content: %w", err)) + } + + fmt.Printf("Document size: %d bytes\n", len(content)) + + // Output: + // Folder "digiposte-go-sdk Example" created + // Document "document.txt" created + // Document content type: text/plain;charset=UTF-8 + // Document size: 134 bytes + // Trashed 1 document(s) + // Permanently deleted 1 document(s) +} diff --git a/v1/folder.go b/v1/folder.go index bfdb217..1c89816 100644 --- a/v1/folder.go +++ b/v1/folder.go @@ -10,9 +10,13 @@ import ( "time" ) +type FolderID digiposteID + +const RootFolderID FolderID = "" + // Folder represents a Digiposte folder. type Folder struct { - InternalID ID `json:"id"` + InternalID FolderID `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -53,7 +57,7 @@ func (c *Client) GetTrashedFolders(ctx context.Context) (*SearchFoldersResult, e } // RenameFolder renames a folder. -func (c *Client) RenameFolder(ctx context.Context, internalID ID, name string) (*Folder, error) { +func (c *Client) RenameFolder(ctx context.Context, internalID FolderID, name string) (*Folder, error) { endpoint := "/v3/folder/" + url.PathEscape(string(internalID)) + "/rename/" + url.PathEscape(name) req, err := c.apiRequest(ctx, http.MethodPut, endpoint, nil) @@ -67,7 +71,7 @@ func (c *Client) RenameFolder(ctx context.Context, internalID ID, name string) ( } // CreateFolder creates a folder. -func (c *Client) CreateFolder(ctx context.Context, parentID ID, name string) (*Folder, error) { +func (c *Client) CreateFolder(ctx context.Context, parentID FolderID, name string) (*Folder, error) { body, err := json.Marshal(map[string]interface{}{ "parent_id": parentID, "name": name, @@ -76,7 +80,7 @@ func (c *Client) CreateFolder(ctx context.Context, parentID ID, name string) (*F return nil, fmt.Errorf("marshal body: %w", err) } - req, err := c.apiRequest(ctx, http.MethodPut, "/v3/folder", bytes.NewReader(body)) + req, err := c.apiRequest(ctx, http.MethodPost, "/v3/folder", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } diff --git a/v1/folder_example_test.go b/v1/folder_example_test.go new file mode 100644 index 0000000..9764130 --- /dev/null +++ b/v1/folder_example_test.go @@ -0,0 +1,236 @@ +package digiposte_test + +import ( + "context" + "fmt" + + "github.com/holyhope/digiposte-go-sdk/v1" +) + +func ExampleClient_ListFolders() { + ctx := context.Background() + + // Create a new authenticated HTTP client using the following environment variables: + // - DIGIPOSTE_API + // - DIGIPOSTE_URL + // - DIGIPOSTE_USERNAME + // - DIGIPOSTE_PASSWORD + // - DIGIPOSTE_OTP_SECRET + client, err := DigiposteClient(ctx) + if err != nil { + panic(fmt.Errorf("new digiposte client: %w", err)) + } + + /* Create folders */ + + folder, err := client.CreateFolder(ctx, digiposte.RootFolderID, "digiposte-go-sdk ExampleClient_ListFolders") + if err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + fmt.Printf("Folder %q created\n", folder.Name) + + /* List folders */ + + folders, err := client.ListFolders(ctx) + if err != nil { + panic(fmt.Errorf("list folders: %w", err)) + } + + for _, f := range folders.Folders { + if f.InternalID == folder.InternalID { + fmt.Print("Folder found\n") + } + } + + /* Trash the folder */ + + if err := client.Trash(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("trash: %w", err)) + } + + fmt.Printf("Folder %q trashed\n", folder.Name) + + /* Delete the folder */ + + if err := client.Delete(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("delete: %w", err)) + } + + fmt.Printf("Folder %q deleted\n", folder.Name) + // Output: + // Folder "digiposte-go-sdk ExampleClient_ListFolders" created + // Folder found + // Folder "digiposte-go-sdk ExampleClient_ListFolders" trashed + // Folder "digiposte-go-sdk ExampleClient_ListFolders" deleted +} + +func ExampleClient_GetTrashedFolders() { + ctx := context.Background() + + /* Create a new authenticated HTTP client using the following environment variables: + * - DIGIPOSTE_API + * - DIGIPOSTE_URL + * - DIGIPOSTE_USERNAME + * - DIGIPOSTE_PASSWORD + * - DIGIPOSTE_OTP_SECRET + */ + + client, err := DigiposteClient(ctx) + if err != nil { + panic(fmt.Errorf("new digiposte client: %w", err)) + } + + /* Create folders */ + + folder, err := client.CreateFolder(ctx, digiposte.RootFolderID, "digiposte-go-sdk ExampleClient_GetTrashedFolders") + if err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + fmt.Printf("Folder %q created\n", folder.Name) + + /* Trash the folder */ + + if err := client.Trash(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("trash: %w", err)) + } + + fmt.Printf("Folder %q trashed\n", folder.Name) + + /* Get trashed folders */ + + folders, err := client.GetTrashedFolders(ctx) + if err != nil { + panic(fmt.Errorf("get trashed folders: %w", err)) + } + + for _, f := range folders.Folders { + if f.InternalID == folder.InternalID { + fmt.Print("Trashed folder found\n") + } + } + + /* Delete the folder */ + + if err := client.Delete(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("delete: %w", err)) + } + + fmt.Printf("Folder %q deleted\n", folder.Name) + + // Output: + // Folder "digiposte-go-sdk ExampleClient_GetTrashedFolders" created + // Folder "digiposte-go-sdk ExampleClient_GetTrashedFolders" trashed + // Trashed folder found + // Folder "digiposte-go-sdk ExampleClient_GetTrashedFolders" deleted +} + +func ExampleClient_RenameFolder() { + ctx := context.Background() + + /* Create a new authenticated HTTP client using the following environment variables: + * - DIGIPOSTE_API + * - DIGIPOSTE_URL + * - DIGIPOSTE_USERNAME + * - DIGIPOSTE_PASSWORD + * - DIGIPOSTE_OTP_SECRET + */ + + client, err := DigiposteClient(ctx) + if err != nil { + panic(fmt.Errorf("new digiposte client: %w", err)) + } + + /* Create folders */ + + folder, err := client.CreateFolder(ctx, digiposte.RootFolderID, "digiposte-go-sdk ExampleClient_R3n4m4F0ld3r") + if err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + fmt.Printf("Folder %q created\n", folder.Name) + + folder, err = client.RenameFolder(ctx, folder.InternalID, "digiposte-go-sdk ExampleClient_RenameFolder") + if err != nil { + panic(fmt.Errorf("rename folder: %w", err)) + } + + fmt.Printf("Folder renamed to %q\n", folder.Name) + + /* Trash the folder */ + + if err := client.Trash(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("trash: %w", err)) + } + + fmt.Printf("Folder %q trashed\n", folder.Name) + + /* Delete the folder */ + + if err := client.Delete(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("delete: %w", err)) + } + + fmt.Printf("Folder %q deleted\n", folder.Name) + + // Output: + // Folder "digiposte-go-sdk ExampleClient_R3n4m4F0ld3r" created + // Folder renamed to "digiposte-go-sdk ExampleClient_RenameFolder" + // Folder "digiposte-go-sdk ExampleClient_RenameFolder" trashed + // Folder "digiposte-go-sdk ExampleClient_RenameFolder" deleted +} + +func ExampleClient_CreateFolder() { + ctx := context.Background() + + /* Create a new authenticated HTTP client using the following environment variables: + * - DIGIPOSTE_API + * - DIGIPOSTE_URL + * - DIGIPOSTE_USERNAME + * - DIGIPOSTE_PASSWORD + * - DIGIPOSTE_OTP_SECRET + */ + + client, err := DigiposteClient(ctx) + if err != nil { + panic(fmt.Errorf("new digiposte client: %w", err)) + } + + /* Create folders */ + + folder, err := client.CreateFolder(ctx, digiposte.RootFolderID, "digiposte-go-sdk ExampleClient_CreateFolder") + if err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + fmt.Printf("Folder %q created\n", folder.Name) + + if _, err := client.CreateFolder(ctx, folder.InternalID, "sub-folder"); err != nil { + panic(fmt.Errorf("create folder: %w", err)) + } + + fmt.Print("Sub folder created\n") + + /* Trash the top folder */ + + if err := client.Trash(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("trash: %w", err)) + } + + fmt.Printf("Folder %q trashed\n", folder.Name) + + /* Delete the top folder */ + + if err := client.Delete(ctx, nil, []digiposte.FolderID{folder.InternalID}); err != nil { + panic(fmt.Errorf("delete: %w", err)) + } + + fmt.Printf("Folder %q deleted\n", folder.Name) + + // Output: + // Folder "digiposte-go-sdk ExampleClient_CreateFolder" created + // Sub folder created + // Folder "digiposte-go-sdk ExampleClient_CreateFolder" trashed + // Folder "digiposte-go-sdk ExampleClient_CreateFolder" deleted +} diff --git a/v1/folder_test.go b/v1/folder_test.go new file mode 100644 index 0000000..0d31213 --- /dev/null +++ b/v1/folder_test.go @@ -0,0 +1,29 @@ +package digiposte_test + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/holyhope/digiposte-go-sdk/v1" +) + +var _ = ginkgo.Describe("Folder", func() { + ginkgo.Describe("CreateFolder", func() { + var folder *digiposte.Folder + + ginkgo.AfterEach(func(ctx ginkgo.SpecContext) { + gomega.Expect(digiposteClient.Trash(ctx, nil, []digiposte.FolderID{folder.InternalID})).To(gomega.Succeed()) + gomega.Expect(digiposteClient.Delete(ctx, nil, []digiposte.FolderID{folder.InternalID})).To(gomega.Succeed()) + }) + + ginkgo.Context("when the folder does not exist", func() { + ginkgo.It("should create a folder", func(ctx ginkgo.SpecContext) { + var err error + + folder, err = digiposteClient.CreateFolder(ctx, digiposte.RootFolderID, ginkgo.CurrentSpecReport().FullText()) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(folder.InternalID).ToNot(gomega.BeEmpty()) + }) + }) + }) +}) diff --git a/v1/share.go b/v1/share.go index d853ef0..8ff192e 100644 --- a/v1/share.go +++ b/v1/share.go @@ -10,12 +10,14 @@ import ( "time" ) +type ShareID digiposteID + const sharePrefix = "/v3/share/" // Share represents a share. type Share struct { - InternalID ID `json:"id"` - ShortID ID `json:"short_id"` + InternalID ShareID `json:"id"` + ShortID string `json:"short_id"` SecurityCode string `json:"security_code"` ShortURL string `json:"short_url"` Title string `json:"title"` @@ -51,17 +53,14 @@ func (c *Client) CreateShare(ctx context.Context, startDate, endDate time.Time, return nil, fmt.Errorf("new request: %w", err) } - req.Header.Set("Content-Type", "application/json") - share := new(Share) return share, c.call(req, share) } // SetShareDocuments adds a document to a share. -func (c *Client) SetShareDocuments(ctx context.Context, shareID ID, documentIDs []ID) error { +func (c *Client) SetShareDocuments(ctx context.Context, shareID ShareID, documentIDs []DocumentID) error { body, err := json.Marshal(map[string]interface{}{ - // {"ids":["215dfcdad6044de5b0bfd3cf904820a6"]} "ids": documentIDs, }) if err != nil { @@ -121,7 +120,7 @@ func (c *Client) ListSharesWithDocuments(ctx context.Context) (*ShareResultWithD } // GetShareDocuments returns all documents of a share. -func (c *Client) GetShareDocuments(ctx context.Context, shareID ID) (*SearchDocumentsResult, error) { +func (c *Client) GetShareDocuments(ctx context.Context, shareID ShareID) (*SearchDocumentsResult, error) { endpoint := sharePrefix + url.PathEscape(string(shareID)) + "/documents" req, err := c.apiRequest(ctx, http.MethodGet, endpoint, nil) @@ -135,7 +134,7 @@ func (c *Client) GetShareDocuments(ctx context.Context, shareID ID) (*SearchDocu } // GetShare returns a share. -func (c *Client) GetShare(ctx context.Context, shareID ID) (*Share, error) { +func (c *Client) GetShare(ctx context.Context, shareID ShareID) (*Share, error) { endpoint := sharePrefix + url.PathEscape(string(shareID)) req, err := c.apiRequest(ctx, http.MethodGet, endpoint, nil) @@ -149,7 +148,7 @@ func (c *Client) GetShare(ctx context.Context, shareID ID) (*Share, error) { } // DeleteShare deletes a share. -func (c *Client) DeleteShare(ctx context.Context, shareID ID) error { +func (c *Client) DeleteShare(ctx context.Context, shareID ShareID) error { endpoint := sharePrefix + url.PathEscape(string(shareID)) req, err := c.apiRequest(ctx, http.MethodDelete, endpoint, nil) diff --git a/v1/testdata/document.txt b/v1/testdata/document.txt new file mode 100644 index 0000000..03902e1 --- /dev/null +++ b/v1/testdata/document.txt @@ -0,0 +1,4 @@ +Hello world! + +Thank you for using digiposte-go-sdk. +If you like it, please support us on GitHub: https://github.com/sponsors/holyhope diff --git a/v1/v1_suite_test.go b/v1/v1_suite_test.go new file mode 100644 index 0000000..166951b --- /dev/null +++ b/v1/v1_suite_test.go @@ -0,0 +1,145 @@ +package digiposte_test + +import ( + "context" + "fmt" + "net/http" + "os" + "sync" + "testing" + "time" + + "github.com/go-rod/rod/lib/launcher" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "golang.org/x/time/rate" + + digipoauth "github.com/holyhope/digiposte-go-sdk/login" + "github.com/holyhope/digiposte-go-sdk/login/chrome" + "github.com/holyhope/digiposte-go-sdk/v1" +) + +func DigiposteClient(ctx context.Context) (*digiposte.Client, error) { + digiposteClientLock.Lock() + defer digiposteClientLock.Unlock() + + if digiposteClient != nil { + return digiposteClient, nil + } + + // or use digiposte.DefaultDocumentURL + // Reduce the test duration + client, err := newDigiposteClient(ctx) + if err != nil { + return nil, err + } + + digiposteClient = client + + return digiposteClient, nil +} + +func newDigiposteClient(ctx context.Context) (*digiposte.Client, error) { + path, err := getChrome(ctx) + if err != nil { + return nil, fmt.Errorf("get chrome: %w", err) + } + + documentURL := os.Getenv("DIGIPOSTE_URL") + + chromeMethod, err := chrome.New( + chrome.WithURL(documentURL), + chrome.WithRefreshFrequency(500*time.Millisecond), + chrome.WithScreenShortOnError(), + chrome.WithTimeout(3*time.Minute), + chrome.WithBinary(path), + ) + if err != nil { + return nil, fmt.Errorf("new chrome: %w", err) + } + + client, err := digiposte.NewAuthenticatedClient(ctx, &http.Client{ + CheckRedirect: nil, + Jar: nil, + Timeout: 0, + Transport: &rateLimitedTransport{ + RoundTripper: http.DefaultTransport, + rateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + }, + }, &digiposte.Config{ + APIURL: os.Getenv("DIGIPOSTE_API"), + DocumentURL: documentURL, + LoginMethod: chromeMethod, + Credentials: &digipoauth.Credentials{ + Username: os.Getenv("DIGIPOSTE_USERNAME"), + Password: os.Getenv("DIGIPOSTE_PASSWORD"), + OTPSecret: os.Getenv("DIGIPOSTE_OTP_SECRET"), + }, + }) + if err != nil { + screenshot, ok := chrome.GetScreenShot(err) + if ok { + if err := os.WriteFile("screenshot.png", screenshot, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "Failed to save the screenshot: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Screenshot saved to %q\n", "screenshot.png") + } + } + + return nil, fmt.Errorf("new client: %w", err) + } + + return client, nil +} + +func getChrome(ctx context.Context) (string, error) { + browser := launcher.NewBrowser() + + browser.Context = ctx + + path, err := browser.Get() + if err != nil { + return "", fmt.Errorf("browser download: %w", err) + } + + if err := browser.Validate(); err != nil { + return "", fmt.Errorf("browser download validation: %w", err) + } + + return path, nil +} + +type rateLimitedTransport struct { + http.RoundTripper + rateLimiter *rate.Limiter +} + +func (t *rateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.rateLimiter.Wait(req.Context()); err != nil { + return nil, fmt.Errorf("rate limited: %w", err) + } + + resp, err := t.RoundTripper.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("round trip: %w", err) + } + + return resp, nil +} + +//nolint:gochecknoglobals +var ( + digiposteClient *digiposte.Client + digiposteClientLock sync.Mutex +) + +var _ = ginkgo.BeforeSuite(func(ctx ginkgo.SpecContext) { + gomega.Expect(DigiposteClient(ctx)).NotTo(gomega.BeNil()) +}) + +func TestV1(t *testing.T) { + t.Parallel() + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "V1 Suite") +}