From 0f4c4f737ddad1abc261fc55dbd4a77afe46579a Mon Sep 17 00:00:00 2001 From: emmdim Date: Fri, 28 Jun 2024 17:50:06 +0200 Subject: [PATCH] Initial Stripe extension, that implements the standar stripe checkout process, assuming an embedded form, and a given stripe price id. Refactors `storage`, `handlersresponse`, `faucet` and `helpers` to be new packages. The min and max number of tokes is defined (should be paremtrized) and just aflat rate is allowed. Adds the handlers: - `/createCheckoutSession/{referral}/{to}` - `/sessionStatus/{session_id}` - /webhook" and the following env vars: -STRIPEKEY -STRIPEPRICEID -STRIPEWEBHOOKSECRET -STRIPEMINQUANTITY -STRIPEMAXQUANTITY -STRIPEDEFAULTQUANTITY --- .env.example | 12 ++ faucet.go | 48 ------- faucet/faucet.go | 74 ++++++++++ handlers.go => faucet/handlers.go | 108 +++++++------- types.go => faucet/types.go | 2 +- go.mod | 1 + go.sum | 3 + .../handlers_response.go | 4 +- helpers.go => helpers/helpers.go | 4 +- main.go | 57 ++++++-- storage/storage.go | 40 +++++- stripehandler/handlers.go | 133 ++++++++++++++++++ stripehandler/stripe.go | 130 +++++++++++++++++ 13 files changed, 501 insertions(+), 115 deletions(-) delete mode 100644 faucet.go create mode 100644 faucet/faucet.go rename handlers.go => faucet/handlers.go (54%) rename types.go => faucet/types.go (96%) rename handlers_response.go => handlersresponse/handlers_response.go (92%) rename helpers.go => helpers/helpers.go (73%) create mode 100644 stripehandler/handlers.go create mode 100644 stripehandler/stripe.go diff --git a/.env.example b/.env.example index 1cbb637..4334d78 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,18 @@ DB_TYPE=pebble BASE_ROUTE=/v2 # authentication types to use (comma separated). Available: open, oauth AUTH=open +# stripe secret key +STRIPE_KEY= +# stripe price id +STRIPE_PRICE_ID= +# min number of tokens +STRIPEMINQUANTITY= +# max number of tokens +STRIPEMAXQUANTITY= +# default number of tokens +STRIPEDEFAULTQUANTITY= +# stripe webhook secret +STRIPE_WEBHOOK_SECRET= RESTART=unless-stopped diff --git a/faucet.go b/faucet.go deleted file mode 100644 index 75174bb..0000000 --- a/faucet.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/vocdoni/vocfaucet/storage" - "go.vocdoni.io/dvote/api" - vfaucet "go.vocdoni.io/dvote/api/faucet" - "go.vocdoni.io/dvote/crypto/ethereum" - "go.vocdoni.io/dvote/vochain" -) - -type faucet struct { - signer *ethereum.SignKeys - authTypes map[string]uint64 - waitPeriod time.Duration - storage *storage.Storage -} - -// prepareFaucetPackage prepares a faucet package, including the signature, for the given address. -// Returns the faucet package as a marshaled json byte array, ready to be sent to the user. -func (f *faucet) prepareFaucetPackage(toAddr common.Address, authTypeName string) (*vfaucet.FaucetResponse, error) { - // check if the auth type is supported - if _, ok := f.authTypes[authTypeName]; !ok { - return nil, fmt.Errorf("auth type %s not supported", authTypeName) - } - - // generate faucet package - fpackage, err := vochain.GenerateFaucetPackage(f.signer, toAddr, f.authTypes[authTypeName]) - if err != nil { - return nil, api.ErrCantGenerateFaucetPkg.WithErr(err) - } - fpackageBytes, err := json.Marshal(vfaucet.FaucetPackage{ - FaucetPayload: fpackage.Payload, - Signature: fpackage.Signature, - }) - if err != nil { - return nil, err - } - // send response - return &vfaucet.FaucetResponse{ - Amount: fmt.Sprint(f.authTypes[authTypeName]), - FaucetPackage: fpackageBytes, - }, nil -} diff --git a/faucet/faucet.go b/faucet/faucet.go new file mode 100644 index 0000000..fff996b --- /dev/null +++ b/faucet/faucet.go @@ -0,0 +1,74 @@ +package faucet + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/vocdoni/vocfaucet/storage" + "go.vocdoni.io/dvote/api" + vFaucet "go.vocdoni.io/dvote/api/faucet" + "go.vocdoni.io/dvote/crypto/ethereum" + "go.vocdoni.io/dvote/vochain" +) + +type Faucet struct { + Signer *ethereum.SignKeys + AuthTypes map[string]uint64 + WaitPeriod time.Duration + Storage *storage.Storage +} + +// prepareFaucetPackage prepares a Faucet package, including the signature, for the given address. +// Returns the Faucet package as a marshaled json byte array, ready to be sent to the user. +func (f *Faucet) prepareFaucetPackage(toAddr common.Address, authTypeName string) (*vFaucet.FaucetResponse, error) { + // check if the auth type is supported + if _, ok := f.AuthTypes[authTypeName]; !ok { + return nil, fmt.Errorf("auth type %s not supported", authTypeName) + } + + // generate Faucet package + fpackage, err := vochain.GenerateFaucetPackage(f.Signer, toAddr, f.AuthTypes[authTypeName]) + if err != nil { + return nil, api.ErrCantGenerateFaucetPkg.WithErr(err) + } + fpackageBytes, err := json.Marshal(vFaucet.FaucetPackage{ + FaucetPayload: fpackage.Payload, + Signature: fpackage.Signature, + }) + if err != nil { + return nil, err + } + // send response + return &vFaucet.FaucetResponse{ + Amount: fmt.Sprint(f.AuthTypes[authTypeName]), + FaucetPackage: fpackageBytes, + }, nil +} + +// PrepareFaucetPackageWithAmount prepares a Faucet package, including the signature, for the given address. +// Returns the Faucet package as a marshaled json byte array, ready to be sent to the user. +func (f *Faucet) PrepareFaucetPackageWithAmount(toAddr common.Address, amount uint64) (*vFaucet.FaucetResponse, error) { + if amount == 0 { + return nil, fmt.Errorf("invalid requested amount: %d", amount) + } + + // generate Faucet package + fpackage, err := vochain.GenerateFaucetPackage(f.Signer, toAddr, amount) + if err != nil { + return nil, api.ErrCantGenerateFaucetPkg.WithErr(err) + } + fpackageBytes, err := json.Marshal(vFaucet.FaucetPackage{ + FaucetPayload: fpackage.Payload, + Signature: fpackage.Signature, + }) + if err != nil { + return nil, err + } + // send response + return &vFaucet.FaucetResponse{ + Amount: fmt.Sprint(amount), + FaucetPackage: fpackageBytes, + }, nil +} diff --git a/handlers.go b/faucet/handlers.go similarity index 54% rename from handlers.go rename to faucet/handlers.go index 24ecdce..f9412a0 100644 --- a/handlers.go +++ b/faucet/handlers.go @@ -1,4 +1,4 @@ -package main +package faucet import ( "encoding/json" @@ -6,6 +6,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/vocdoni/vocfaucet/aragondaohandler" + hr "github.com/vocdoni/vocfaucet/handlersresponse" + "github.com/vocdoni/vocfaucet/helpers" "github.com/vocdoni/vocfaucet/oauthhandler" "go.vocdoni.io/dvote/httprouter" "go.vocdoni.io/dvote/httprouter/apirest" @@ -14,7 +16,7 @@ import ( ) // Register the handlers URLs -func (f *faucet) registerHandlers(api *apirest.API) { +func (f *Faucet) RegisterHandlers(api *apirest.API) { if err := api.RegisterMethod( "/authTypes", "GET", @@ -24,7 +26,7 @@ func (f *faucet) registerHandlers(api *apirest.API) { log.Fatal(err) } - if f.authTypes[AuthTypeOpen] > 0 { + if f.AuthTypes[AuthTypeOpen] > 0 { if err := api.RegisterMethod( "/open/claim/{to}", "GET", @@ -35,7 +37,7 @@ func (f *faucet) registerHandlers(api *apirest.API) { } } - if f.authTypes[AuthTypeOauth] > 0 { + if f.AuthTypes[AuthTypeOauth] > 0 { if err := api.RegisterMethod( "/oauth/claim", "POST", @@ -55,7 +57,7 @@ func (f *faucet) registerHandlers(api *apirest.API) { } } - if f.authTypes[AuthTypeAragonDao] > 0 { + if f.AuthTypes[AuthTypeAragonDao] > 0 { if err := api.RegisterMethod( "/aragondao/claim", "POST", @@ -68,42 +70,42 @@ func (f *faucet) registerHandlers(api *apirest.API) { } // Returns the list of supported auth types -func (f *faucet) authTypesHandler(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { +func (f *Faucet) authTypesHandler(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { data := &AuthTypes{ - AuthTypes: f.authTypes, - WaitSeconds: uint64(f.waitPeriod.Seconds()), + AuthTypes: f.AuthTypes, + WaitSeconds: uint64(f.WaitPeriod.Seconds()), } - return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) + return ctx.Send(new(hr.HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) } -// Open faucet handler (does no logic but flood protection) -func (f *faucet) authOpenHandler(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { - amount, ok := f.authTypes[AuthTypeOpen] +// Open Faucet handler (does no logic but flood protection) +func (f *Faucet) authOpenHandler(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + amount, ok := f.AuthTypes[AuthTypeOpen] if !ok || amount == 0 { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrUnsupportedAuthType).MustMarshall(), CodeErrUnsupportedAuthType) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrUnsupportedAuthType).MustMarshall(), hr.CodeErrUnsupportedAuthType) } - addr, err := stringToAddress(ctx.URLParam("to")) + addr, err := helpers.StringToAddress(ctx.URLParam("to")) if err != nil { return err } - if funded, t := f.storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeOpen); funded { + if funded, t := f.Storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeOpen); funded { errReason := fmt.Sprintf("address %s already funded, wait until %s", addr.Hex(), t) - return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), CodeErrFlood) + return ctx.Send(new(hr.HandlerResponse).SetError(errReason).MustMarshall(), hr.CodeErrFlood) } data, err := f.prepareFaucetPackage(addr, AuthTypeOpen) if err != nil { return err } - if err := f.storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeOpen); err != nil { + if err := f.Storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeOpen); err != nil { return err } - return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) + return ctx.Send(new(hr.HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) } -// oAuth faucet handler -func (f *faucet) authOAuthHandler(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { - amount, ok := f.authTypes[AuthTypeOauth] +// oAuth Faucet handler +func (f *Faucet) authOAuthHandler(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { + amount, ok := f.AuthTypes[AuthTypeOauth] if !ok || amount == 0 { return ctx.Send([]byte("auth type oAuth not supported"), apirest.HTTPstatusInternalErr) } @@ -116,52 +118,52 @@ func (f *faucet) authOAuthHandler(msg *apirest.APIdata, ctx *httprouter.HTTPCont } newRequest := r{} if err := json.Unmarshal(msg.Data, &newRequest); err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrIncorrectParams) + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrIncorrectParams) } - addr, err := stringToAddress(newRequest.Recipient) + addr, err := helpers.StringToAddress(newRequest.Recipient) if err != nil { return err } - if funded, t := f.storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeOauth); funded { + if funded, t := f.Storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeOauth); funded { errReason := fmt.Sprintf("address %s already funded, wait until %s", addr.Hex(), t) - return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), CodeErrFlood) + return ctx.Send(new(hr.HandlerResponse).SetError(errReason).MustMarshall(), hr.CodeErrFlood) } // Convert the provided "code" to an oAuth Token providers, err := oauthhandler.InitProviders() if err != nil { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrInitProviders).MustMarshall(), CodeErrInitProviders) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrInitProviders).MustMarshall(), hr.CodeErrInitProviders) } provider, ok := providers[newRequest.Provider] if !ok { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrOauthProviderNotFound).MustMarshall(), CodeErrOauthProviderNotFound) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrOauthProviderNotFound).MustMarshall(), hr.CodeErrOauthProviderNotFound) } token, err := provider.GetOAuthToken(newRequest.Code, newRequest.RedirectURL) if err != nil { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrOauthProviderError).MustMarshall(), CodeErrOauthProviderError) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrOauthProviderError).MustMarshall(), hr.CodeErrOauthProviderError) } profileRaw, err := provider.GetOAuthProfile(token) if err != nil { log.Warnw("error obtaining the profile", "err", err) - return ctx.Send(new(HandlerResponse).SetError(ReasonErrOauthProviderError).MustMarshall(), CodeErrOauthProviderError) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrOauthProviderError).MustMarshall(), hr.CodeErrOauthProviderError) } var profile map[string]interface{} if err := json.Unmarshal(profileRaw, &profile); err != nil { log.Warnw("error marshalling the profile", "err", err) - return ctx.Send(new(HandlerResponse).SetError(ReasonErrOauthProviderError).MustMarshall(), CodeErrOauthProviderError) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrOauthProviderError).MustMarshall(), hr.CodeErrOauthProviderError) } // Check if the oauth profile is already funded fundedProfileField := profile[provider.UsernameField].(string) fundedAuthType := "oauth_" + newRequest.Provider - if funded, t := f.storage.CheckFundedUserWithWaitTime([]byte(fundedProfileField), fundedAuthType); funded { + if funded, t := f.Storage.CheckFundedUserWithWaitTime([]byte(fundedProfileField), fundedAuthType); funded { errReason := fmt.Sprintf("user %s already funded, wait until %s", fundedProfileField, t) - return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), CodeErrFlood) + return ctx.Send(new(hr.HandlerResponse).SetError(errReason).MustMarshall(), hr.CodeErrFlood) } data, err := f.prepareFaucetPackage(addr, AuthTypeOauth) @@ -170,21 +172,21 @@ func (f *faucet) authOAuthHandler(msg *apirest.APIdata, ctx *httprouter.HTTPCont } // Add address and profile to the funded list - if err := f.storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeOauth); err != nil { + if err := f.Storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeOauth); err != nil { return err } - if err := f.storage.AddFundedUserWithWaitTime([]byte(fundedProfileField), fundedAuthType); err != nil { + if err := f.Storage.AddFundedUserWithWaitTime([]byte(fundedProfileField), fundedAuthType); err != nil { return err } - return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) + return ctx.Send(new(hr.HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) } -// oAuth faucet handler (returns the oAuth URL) -func (f *faucet) authOAuthUrl(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { +// oAuth Faucet handler (returns the oAuth URL) +func (f *Faucet) authOAuthUrl(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { providers, err := oauthhandler.InitProviders() if err != nil { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrInitProviders).MustMarshall(), CodeErrInitProviders) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrInitProviders).MustMarshall(), hr.CodeErrInitProviders) } type r struct { @@ -194,25 +196,25 @@ func (f *faucet) authOAuthUrl(msg *apirest.APIdata, ctx *httprouter.HTTPContext) } newAuthUrlRequest := r{} if err := json.Unmarshal(msg.Data, &newAuthUrlRequest); err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrIncorrectParams) + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrIncorrectParams) } provider, ok := providers[newAuthUrlRequest.Provider] if !ok { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrOauthProviderNotFound).MustMarshall(), CodeErrOauthProviderNotFound) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrOauthProviderNotFound).MustMarshall(), hr.CodeErrOauthProviderNotFound) } type urlResponse struct { Url string `json:"url"` } authURL := urlResponse{Url: provider.GetAuthURL(newAuthUrlRequest.RedirectURL, newAuthUrlRequest.State)} - return ctx.Send(new(HandlerResponse).Set(authURL).MustMarshall(), apirest.HTTPstatusOK) + return ctx.Send(new(hr.HandlerResponse).Set(authURL).MustMarshall(), apirest.HTTPstatusOK) } -func (f *faucet) authAragonDaoHandler(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { +func (f *Faucet) authAragonDaoHandler(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { var err error - amount, ok := f.authTypes[AuthTypeAragonDao] + amount, ok := f.AuthTypes[AuthTypeAragonDao] if !ok || amount == 0 { return ctx.Send([]byte("auth type AragonDao not supported"), apirest.HTTPstatusInternalErr) } @@ -224,25 +226,25 @@ func (f *faucet) authAragonDaoHandler(msg *apirest.APIdata, ctx *httprouter.HTTP } newRequest := r{} if err := json.Unmarshal(msg.Data, &newRequest); err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrIncorrectParams) + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrIncorrectParams) } // Obtains the URL and verifies the signature is from today var addr common.Address if addr, err = aragondaohandler.VerifyAragonDaoRequest(newRequest.Data, newRequest.Signature); err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrAragonDaoSignature) + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrAragonDaoSignature) } // Check if the address is already funded - if funded, t := f.storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeAragonDao); funded { + if funded, t := f.Storage.CheckFundedUserWithWaitTime(addr.Bytes(), AuthTypeAragonDao); funded { errReason := fmt.Sprintf("address %s already funded, wait until %s", addr.Hex(), t) - return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), CodeErrFlood) + return ctx.Send(new(hr.HandlerResponse).SetError(errReason).MustMarshall(), hr.CodeErrFlood) } // Check if the address is an Aragon DAO address by checking to AragonGraphQL if newRequest.Network != "" { if isAragonDao, _ := aragondaohandler.IsAragonDaoAddress(addr, newRequest.Network); !isAragonDao { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrAragonDaoAddress).MustMarshall(), CodeErrAragonDaoAddress) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrAragonDaoAddress).MustMarshall(), hr.CodeErrAragonDaoAddress) } } else { // Check all networks found := false @@ -253,18 +255,18 @@ func (f *faucet) authAragonDaoHandler(msg *apirest.APIdata, ctx *httprouter.HTTP } } if !found { - return ctx.Send(new(HandlerResponse).SetError(ReasonErrAragonDaoAddress).MustMarshall(), CodeErrAragonDaoAddress) + return ctx.Send(new(hr.HandlerResponse).SetError(hr.ReasonErrAragonDaoAddress).MustMarshall(), hr.CodeErrAragonDaoAddress) } } data, err := f.prepareFaucetPackage(addr, AuthTypeAragonDao) if err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrInternalError) + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) } - if err := f.storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeAragonDao); err != nil { - return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrInternalError) + if err := f.Storage.AddFundedUserWithWaitTime(addr.Bytes(), AuthTypeAragonDao); err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) } - return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) + return ctx.Send(new(hr.HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) } diff --git a/types.go b/faucet/types.go similarity index 96% rename from types.go rename to faucet/types.go index ba4cdd8..9238a9a 100644 --- a/types.go +++ b/faucet/types.go @@ -1,4 +1,4 @@ -package main +package faucet import "fmt" diff --git a/go.mod b/go.mod index 3559c36..36e9102 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/ethereum/go-ethereum v1.13.4 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 + github.com/stripe/stripe-go/v78 v78.3.0 go.vocdoni.io/dvote v1.10.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index d6718b3..6e71849 100644 --- a/go.sum +++ b/go.sum @@ -1465,6 +1465,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/stripe/stripe-go/v78 v78.3.0 h1:FYlKhJKZdZ/1vATbuIN4T107DeL7w9oV13IcPOEwyPQ= +github.com/stripe/stripe-go/v78 v78.3.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= @@ -1801,6 +1803,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/handlers_response.go b/handlersresponse/handlers_response.go similarity index 92% rename from handlers_response.go rename to handlersresponse/handlers_response.go index 671a2a8..aa9440d 100644 --- a/handlers_response.go +++ b/handlersresponse/handlers_response.go @@ -1,4 +1,4 @@ -package main +package handlersresponse import ( "encoding/json" @@ -19,6 +19,8 @@ const ( CodeErrIncorrectParams = 408 CodeErrInternalError = 409 ReasonErrAragonDaoAddress = "could not find the signer address in any Aragon DAO" + CodeErrProviderError = 410 + ReasonErrProviderError = "error obtaining the oAuthToken" ) // HandlerResponse is the response format for the Handlers diff --git a/helpers.go b/helpers/helpers.go similarity index 73% rename from helpers.go rename to helpers/helpers.go index b83465b..cefb57b 100644 --- a/helpers.go +++ b/helpers/helpers.go @@ -1,11 +1,11 @@ -package main +package helpers import ( "github.com/ethereum/go-ethereum/common" "go.vocdoni.io/dvote/api" ) -func stringToAddress(addr string) (common.Address, error) { +func StringToAddress(addr string) (common.Address, error) { if !common.IsHexAddress(addr) { return common.Address{}, api.ErrParamToInvalid } diff --git a/main.go b/main.go index 9a92c26..2123e19 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,9 @@ import ( flag "github.com/spf13/pflag" "github.com/spf13/viper" + "github.com/vocdoni/vocfaucet/faucet" "github.com/vocdoni/vocfaucet/storage" + "github.com/vocdoni/vocfaucet/stripehandler" "go.vocdoni.io/dvote/crypto/ethereum" "go.vocdoni.io/dvote/db" "go.vocdoni.io/dvote/httprouter" @@ -21,7 +23,7 @@ import ( ) var supportedAuthTypes = map[string]string{ - "open": "without authentication, anyone can use the faucet", + "open": "witho, anyone can use the faucet", "oauth": "with oauth2 authentication", "aragondao": "signed message from addresses belonging to at least one aragon dao", } @@ -38,6 +40,12 @@ func main() { flag.String("amounts", "100", "tokens to send per request (comma separated), the order must match the auth types") flag.Duration("waitPeriod", 1*time.Hour, "wait period between requests for the same user") flag.StringP("dbType", "t", db.TypePebble, fmt.Sprintf("key-value db type [%s,%s,%s]", db.TypePebble, db.TypeLevelDB, db.TypeMongo)) + flag.String("stripeKey", "", "stripe secret key") + flag.String("stripePriceId", "", "stripe price id") + flag.Int64("stripeMinQuantity", 100, "stripe min number of tokens") + flag.Int64("stripeMaxQuantity", 100000, "stripe max number of tokens") + flag.Int64("stripeDefaultQuantity", 100, "stripe default number of tokens") + flag.String("stripeWebhookSecret", "", "stripe webhook secret key") flag.Parse() // Setting up viper @@ -87,6 +95,24 @@ func main() { if err := viper.BindPFlag("dbType", flag.Lookup("dbType")); err != nil { panic(err) } + if err := viper.BindPFlag("stripeKey", flag.Lookup("stripeKey")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripePriceId", flag.Lookup("stripePriceId")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeMinQuantity", flag.Lookup("stripeMinQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeMaxQuantity", flag.Lookup("stripeMaxQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeDefaultQuantity", flag.Lookup("stripeDefaultQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeWebhookSecret", flag.Lookup("stripeWebhookSecret")); err != nil { + panic(err) + } // check if config file exists _, err := os.Stat(path.Join(dataDir, "faucet.yml")) @@ -122,8 +148,15 @@ func main() { privKey := viper.GetString("privKey") auth := viper.GetString("auth") amounts := viper.GetString("amounts") + waitPeriod := viper.GetDuration("waitPeriod") dbType := viper.GetString("dbType") + stripeKey := viper.GetString("stripeKey") + stripePriceId := viper.GetString("stripePriceId") + stripeMinQuantity := viper.GetInt64("stripeMinQuantity") + stripeMaxQuantity := viper.GetInt64("stripeMaxQuantity") + stripeDefaultQuantity := viper.GetInt64("stripeDefaultQuantity") + stripeWebhookSecret := viper.GetString("stripeWebhookSecret") // parse auth types and amounts authNames := strings.Split(auth, ",") @@ -175,13 +208,19 @@ func main() { if err != nil { log.Fatal(err) } - // create the faucet instance - f := faucet{ - signer: &signer, - authTypes: authTypes, - waitPeriod: waitPeriod, - storage: storage, + f := faucet.Faucet{ + Signer: &signer, + AuthTypes: authTypes, + WaitPeriod: waitPeriod, + Storage: storage, + } + var s *stripehandler.StripeHandler + if stripeKey != "" && stripeWebhookSecret != "" { + s = stripehandler.NewStripeClient(stripeKey, stripePriceId, stripeWebhookSecret, stripeMinQuantity, stripeMaxQuantity, stripeDefaultQuantity, &f, storage) + log.Info("Stripe enabled") + } else { + log.Info("Stripe not configured") } // init API @@ -191,8 +230,8 @@ func main() { } // register handlers - f.registerHandlers(api) - + f.RegisterHandlers(api) + s.RegisterHandlers(api) log.Infof("API available at %s", baseRoute) log.Info("startup complete") // close if interrupt received diff --git a/storage/storage.go b/storage/storage.go index c21c412..8ec48f0 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -73,7 +73,7 @@ func (st *Storage) AddFundedUserWithWaitTime(userID []byte, authType string) err return tx.Commit() } -// CheckFundedUserWithWaitTime checks if the given text is funded and returns true if it is, within +// checkIsFundedUserID checks if the given text is funded and returns true if it is, within // the wait period time window. Otherwise, it returns false. func (st *Storage) CheckFundedUserWithWaitTime(userID []byte, authType string) (bool, time.Time) { key := append(userID, []byte(authType)...) @@ -84,3 +84,41 @@ func (st *Storage) CheckFundedUserWithWaitTime(userID []byte, authType string) ( wp := binary.LittleEndian.Uint64(wpBytes) return wp >= uint64(time.Now().Unix()), time.Unix(int64(wp), 0) } + +// AddPendingStripeSession adds a pending Stripe session to the storage. +// It takes a sessionID string as a parameter and returns an error if any. +func (st *Storage) AddPendingStripeSession(sessionID string) error { + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Set([]byte(sessionID), []byte("true")); err != nil { + log.Error(err) + } + return tx.Commit() +} + +// GetPendingStripeSession retrieves the pending status of a Stripe session by session ID. +// It returns a boolean indicating whether the session is pending and an error, if any. +func (st *Storage) GetPendingStripeSession(sessionID string) (bool, error) { + data, err := st.kv.Get([]byte(sessionID)) + if err != nil { + if err == db.ErrKeyNotFound { + return false, nil + } + return false, err + } + if string(data) == "true" { + return true, nil + } + return false, nil +} + +// RemovePendingStripeSession removes a pending Stripe session from the storage. +// It takes a sessionID as a parameter and returns an error if any occurred. +func (st *Storage) RemovePendingStripeSession(sessionID string) error { + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Delete([]byte(sessionID)); err != nil { + return err + } + return tx.Commit() +} diff --git a/stripehandler/handlers.go b/stripehandler/handlers.go new file mode 100644 index 0000000..2830eef --- /dev/null +++ b/stripehandler/handlers.go @@ -0,0 +1,133 @@ +package stripehandler + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + hr "github.com/vocdoni/vocfaucet/handlersresponse" + "github.com/vocdoni/vocfaucet/helpers" + "go.vocdoni.io/dvote/httprouter" + "go.vocdoni.io/dvote/httprouter/apirest" + "go.vocdoni.io/dvote/log" +) + +// Register the handlers URLs +func (s *StripeHandler) RegisterHandlers(api *apirest.API) { + if err := api.RegisterMethod( + "/createCheckoutSession/{referral}/{to}", + "POST", + apirest.MethodAccessTypePublic, + s.createCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/createCheckoutSession/{referral}/{to}/{amount}", + "POST", + apirest.MethodAccessTypePublic, + s.createCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/sessionStatus/{session_id}", + "GET", + apirest.MethodAccessTypePublic, + s.retrieveCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/webhook", + "POST", + apirest.MethodAccessTypePublic, + s.handleWebhook, + ); err != nil { + log.Fatal(err) + } +} + +// createCheckoutSession creates a new Stripe Checkout session +func (s *StripeHandler) createCheckoutSession(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + to := ctx.URLParam("to") + referral := ctx.URLParam("referral") + defaultAmount := s.DefaultAmount + if amount := ctx.URLParam("amount"); amount != "" { + var err error + defaultAmount, err = strconv.ParseInt(amount, 10, 64) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrIncorrectParams) + } + } + sess, err := s.CreateCheckoutSession(defaultAmount, to, referral) + if err != nil { + errReason := fmt.Sprintf("session.New: %v", err) + return ctx.Send(new(hr.HandlerResponse).SetError(errReason).MustMarshall(), hr.CodeErrProviderError) + } + data := &struct { + ClientSecret string `json:"clientSecret"` + }{ + ClientSecret: sess.ClientSecret, + } + return ctx.Send(new(hr.HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) +} + +func (s *StripeHandler) retrieveCheckoutSession(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + sessionId := ctx.URLParam("session_id") + status, err := s.RetrieveCheckoutSession(sessionId) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrProviderError) + } + toFund, err := s.Storage.GetPendingStripeSession(sessionId) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) + } + if toFund { + data, err := s.processPaymentTransfer(status.Quantity, status.Recipient) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) + } + if err := s.Storage.RemovePendingStripeSession(sessionId); err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) + } + status.FaucetPackage = data + return ctx.Send(new(hr.HandlerResponse).Set(status).MustMarshall(), apirest.HTTPstatusOK) + } + return ctx.Send(new(hr.HandlerResponse).Set(status).MustMarshall(), apirest.HTTPstatusOK) + +} + +func (s *StripeHandler) handleWebhook(apiData *apirest.APIdata, ctx *httprouter.HTTPContext) error { + sig := ctx.Request.Header.Get("Stripe-Signature") + // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key + sessionId, err := s.HandleWebhook(apiData, sig) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), http.StatusBadRequest) + } + err = s.Storage.AddPendingStripeSession(sessionId) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), http.StatusBadRequest) + } + return ctx.Send([]byte("success"), http.StatusOK) +} + +func (s *StripeHandler) processPaymentTransfer(amount int64, to string) ([]byte, error) { + if amount == 0 { + return nil, errors.New("invalid requested amount") + } + addr, err := helpers.StringToAddress(to) + if err != nil { + return nil, err + } + data, err := s.Faucet.PrepareFaucetPackageWithAmount(addr, uint64(amount)) + if err != nil { + return nil, err + } + + return data.FaucetPackage, nil +} diff --git a/stripehandler/stripe.go b/stripehandler/stripe.go new file mode 100644 index 0000000..6ac5a86 --- /dev/null +++ b/stripehandler/stripe.go @@ -0,0 +1,130 @@ +package stripehandler + +import ( + "encoding/json" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/checkout/session" + "github.com/stripe/stripe-go/v78/webhook" + "github.com/vocdoni/vocfaucet/faucet" + "github.com/vocdoni/vocfaucet/storage" + "go.vocdoni.io/dvote/httprouter/apirest" +) + +// StripeHandler represents the configuration for the stripe a provider for handling Stripe payments. +type StripeHandler struct { + Key string // The API key for the Stripe account. + PriceId string // The ID of the price associated with the product. + MinQuantity int64 // The minimum quantity allowed for the product. + MaxQuantity int64 // The maximum quantity allowed for the product. + DefaultAmount int64 // The default amount for the product. + WebhookSecret string // The secret used to verify Stripe webhook events. + Storage *storage.Storage // The storage instance for the faucet. + Faucet *faucet.Faucet // The faucet instance. +} + +// ReturnStatus represents the response status and data returned by the client. +type ReturnStatus struct { + Status string `json:"status"` + CustomerEmail string `json:"customer_email"` + FaucetPackage []byte `json:"faucet_package"` + Recipient string `json:"recipient"` + Quantity int64 `json:"quantity"` +} + +// NewStripeClient creates a new instance of the StripeHandler struct with the provided parameters. +// It sets the Stripe API key, price ID, webhook secret, minimum quantity, maximum quantity, and default amount. +// Returns a pointer to the created StripeHandler. +func NewStripeClient(key, priceId, webhookSecret string, minQuantity, maxQuantity, defaultAmount int64, faucet *faucet.Faucet, storage *storage.Storage) *StripeHandler { + stripe.Key = key + return &StripeHandler{ + PriceId: priceId, + MinQuantity: minQuantity, + MaxQuantity: maxQuantity, + DefaultAmount: defaultAmount, + WebhookSecret: webhookSecret, + Storage: storage, + Faucet: faucet, + } +} + +// CreateCheckoutSession creates a new Stripe checkout session. +// It takes the defaultAmount, to, and referral as parameters and returns a pointer to a stripe.CheckoutSession and an error. +// The defaultAmount parameter specifies the default quantity for the checkout session. +// The to parameter is the client reference ID for the checkout session. +// The referral parameter is the referral URL for the checkout session. +// The function constructs a stripe.CheckoutSessionParams object with the provided parameters and creates a new session using the session.New function. +// If the session creation is successful, it returns the session pointer, otherwise it returns an error. +func (s *StripeHandler) CreateCheckoutSession(defaultAmount int64, to string, referral string) (*stripe.CheckoutSession, error) { + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(to), + UIMode: stripe.String("embedded"), + ReturnURL: stripe.String("http://" + referral + ":5173/stripe/return/{CHECKOUT_SESSION_ID}"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(s.PriceId), + AdjustableQuantity: &stripe.CheckoutSessionLineItemAdjustableQuantityParams{ + Enabled: stripe.Bool(true), + Minimum: stripe.Int64(int64(s.MinQuantity)), + Maximum: stripe.Int64(int64(s.MaxQuantity)), + }, + Quantity: stripe.Int64(int64(defaultAmount)), + }, + }, + Metadata: map[string]string{ + "to": to, + "referral": referral, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + ses, err := session.New(params) + if err != nil { + return nil, err + } + return ses, nil +} + +// RetrieveCheckoutSession retrieves a checkout session from Stripe by session ID. +// It returns a ReturnStatus object and an error if any. +// The ReturnStatus object contains information about the session status, customer email, +// faucet package, recipient, and quantity. +func (s *StripeHandler) RetrieveCheckoutSession(sessionID string) (*ReturnStatus, error) { + params := &stripe.CheckoutSessionParams{} + params.AddExpand("line_items") + sess, err := session.Get(sessionID, params) + if err != nil { + return nil, err + } + lineItems := sess.LineItems + data := &ReturnStatus{ + Status: string(sess.Status), + CustomerEmail: sess.CustomerDetails.Email, + FaucetPackage: nil, + Recipient: sess.Metadata["to"], + Quantity: lineItems.Data[0].Quantity, + } + return data, nil +} + +// HandleWebhook handles the incoming webhook event from Stripe. +// It takes the API data and signature as input parameters and returns the session ID and an error (if any). +// The request body and Stripe-Signature header are passed to ConstructEvent, along with the webhook signing key. +// If the event type is "checkout.session.completed", it unmarshals the event data into a CheckoutSession struct +// and returns the session ID. Otherwise, it returns an empty string. +func (s *StripeHandler) HandleWebhook(apiData *apirest.APIdata, sig string) (string, error) { + // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key + event, err := webhook.ConstructEvent(apiData.Data, sig, s.WebhookSecret) + if err != nil { + return "", err + } + // Handle the checkout.session.completed event + if event.Type == "checkout.session.completed" { + var sess stripe.CheckoutSession + err := json.Unmarshal(event.Data.Raw, &sess) + if err != nil { + return "", err + } + return sess.ID, nil + } + return "", nil +}