diff --git a/.env.example b/.env.example index 1cbb637..bf7a3c3 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,20 @@ WAIT_PERIOD=10m DB_TYPE=pebble # base route for the API (default "/v2") BASE_ROUTE=/v2 -# authentication types to use (comma separated). Available: open, oauth +# authentication types to use (comma separated). Available: open, oauth, stripe 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 90% rename from types.go rename to faucet/types.go index ba4cdd8..88e6d6b 100644 --- a/types.go +++ b/faucet/types.go @@ -1,4 +1,4 @@ -package main +package faucet import "fmt" @@ -14,6 +14,7 @@ const ( AuthTypeOpen = "open" AuthTypeOauth = "oauth" AuthTypeAragonDao = "aragondao" + AuthTypeStripe = "stripe" ) type ErrorResponse struct { 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..5e2df89 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" @@ -24,6 +26,7 @@ var supportedAuthTypes = map[string]string{ "open": "without authentication, anyone can use the faucet", "oauth": "with oauth2 authentication", "aragondao": "signed message from addresses belonging to at least one aragon dao", + "stripe": "with stripe payment", } func main() { @@ -38,6 +41,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 +96,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 +149,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 +209,30 @@ 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 f.AuthTypes[faucet.AuthTypeStripe] > 0 { + s, err = stripehandler.NewStripeClient( + stripeKey, + stripePriceId, + stripeWebhookSecret, + stripeMinQuantity, + stripeMaxQuantity, + stripeDefaultQuantity, + &f, + storage, + ) + if err != nil { + log.Fatalf("stripe initialization error: %s", err) + } else { + log.Infof("stripe enabled with price id %s", stripePriceId) + } } // init API @@ -191,8 +242,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..e59c0a1 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "path/filepath" + "sync" "time" "go.vocdoni.io/dvote/db" @@ -17,6 +18,7 @@ import ( type Storage struct { kv db.Database waitPeriodSeconds uint64 + lock sync.RWMutex } // New creates a new storage instance. @@ -40,6 +42,8 @@ func New(dbType string, dataDir string, waitPeriod time.Duration, dbPrefix []byt // Set sets the given key to the given value. func (st *Storage) Set(key, value []byte) error { + st.lock.Lock() + defer st.lock.Unlock() tx := st.kv.WriteTx() defer tx.Discard() if err := tx.Set(key, value); err != nil { @@ -50,9 +54,23 @@ func (st *Storage) Set(key, value []byte) error { // Get gets the value for the given key. func (st *Storage) Get(key []byte) ([]byte, error) { + st.lock.RLock() + defer st.lock.RUnlock() return st.kv.Get(key) } +// Delete removes an existing value based on the given key +func (st *Storage) Delete(key []byte) error { + st.lock.Lock() + defer st.lock.Unlock() + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Delete(key); err != nil { + return err + } + return tx.Commit() +} + // Close closes the storage. func (st *Storage) Close() error { return st.kv.Close() @@ -73,7 +91,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)...) diff --git a/stripehandler/handlers.go b/stripehandler/handlers.go new file mode 100644 index 0000000..eabd698 --- /dev/null +++ b/stripehandler/handlers.go @@ -0,0 +1,137 @@ +package stripehandler + +import ( + "encoding/json" + "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/{to}", + "POST", + apirest.MethodAccessTypePublic, + s.createCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/createCheckoutSession/{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(msg *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) + } + } + type r struct { + ReturnURL string `json:"returnURL"` + Referral string `json:"referral"` + } + newRequest := r{} + if err := json.Unmarshal(msg.Data, &newRequest); err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrIncorrectParams) + } + sess, err := s.CreateCheckoutSession(defaultAmount, to, newRequest.ReturnURL, newRequest.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) + } + _, err = s.Storage.Get([]byte(sessionId)) + if err != nil { + return ctx.Send(new(hr.HandlerResponse).SetError(err.Error()).MustMarshall(), hr.CodeErrInternalError) + } + 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.Delete([]byte(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) +} + +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.Set([]byte(sessionId), nil) + 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, fmt.Errorf("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..1ec5a0e --- /dev/null +++ b/stripehandler/stripe.go @@ -0,0 +1,134 @@ +package stripehandler + +import ( + "encoding/json" + "errors" + + "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, error) { + if key == "" || priceId == "" || webhookSecret == "" || storage == nil { + return nil, errors.New("missing required parameters") + } + stripe.Key = key + return &StripeHandler{ + PriceId: priceId, + MinQuantity: minQuantity, + MaxQuantity: maxQuantity, + DefaultAmount: defaultAmount, + WebhookSecret: webhookSecret, + Storage: storage, + Faucet: faucet, + }, nil +} + +// 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, returnURL, referral string) (*stripe.CheckoutSession, error) { + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(to), + UIMode: stripe.String("embedded"), + ReturnURL: stripe.String(returnURL + "/{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 +}