Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gnofaucet): Github middleware with cooldown #3808

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions contribs/gnofaucet/coins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"bytes"
"encoding/json"
"io"
"net/http"

tm2Client "github.com/gnolang/faucet/client/http"
"github.com/gnolang/gno/tm2/pkg/crypto"
)

func getAccountBalanceMiddleware(tm2Client *tm2Client.Client, maxBalance int64) func(next http.Handler) http.Handler {
type request struct {
To string `json:"to"`
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
var data request
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

Check warning on line 25 in contribs/gnofaucet/coins.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/coins.go#L23-L25

Added lines #L23 - L25 were not covered by tests

err = json.Unmarshal(body, &data)
r.Body = io.NopCloser(bytes.NewBuffer(body))
balance, err := checkAccountBalance(tm2Client, data.To)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if balance >= maxBalance {
http.Error(w, "accounts is already topped up", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
},
)
}
}

var checkAccountBalance = func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) {
address, err := crypto.AddressFromString(walletAddress)
if err != nil {
return 0, err
}
acc, err := tm2Client.GetAccount(address)
if err != nil {
return 0, err
}
return acc.GetCoins().AmountOf("ugnot"), nil

Check warning on line 53 in contribs/gnofaucet/coins.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/coins.go#L44-L53

Added lines #L44 - L53 were not covered by tests
}
82 changes: 82 additions & 0 deletions contribs/gnofaucet/coins_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

tm2Client "github.com/gnolang/faucet/client/http"
"github.com/stretchr/testify/assert"
)

func mockedCheckAccountBalance(amount int64, err error) func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) {
return func(tm2Client *tm2Client.Client, walletAddress string) (int64, error) {
return amount, err
}
}

func TestGetAccountBalanceMiddleware(t *testing.T) {
maxBalance := int64(1000)

tests := []struct {
name string
requestBody map[string]string
expectedStatus int
expectedBody string
checkBalanceFunc func(tm2Client *tm2Client.Client, walletAddress string) (int64, error)
}{
{
name: "Valid address with low balance (should pass)",
requestBody: map[string]string{"to": "valid_address_low_balance"},
expectedStatus: http.StatusOK,
expectedBody: "next handler reached",
checkBalanceFunc: mockedCheckAccountBalance(500, nil),
},
{
name: "Valid address with high balance (should fail)",
requestBody: map[string]string{"To": "valid_address_high_balance"},
expectedStatus: http.StatusBadRequest,
expectedBody: "accounts is already topped up",
checkBalanceFunc: mockedCheckAccountBalance(2*maxBalance, nil),
},
{
name: "Invalid address (should fail)",
requestBody: map[string]string{"To": "invalid_address"},
expectedStatus: http.StatusBadRequest,
expectedBody: "account not found",
checkBalanceFunc: mockedCheckAccountBalance(2*maxBalance, errors.New("account not found")),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checkAccountBalance = tt.checkBalanceFunc
// Convert request body to JSON
reqBody, _ := json.Marshal(tt.requestBody)

// Create request
req := httptest.NewRequest(http.MethodPost, "/claim", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")

// Create ResponseRecorder
rr := httptest.NewRecorder()

// Mock next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("next handler reached"))
})

// Apply middleware
handler := getAccountBalanceMiddleware(nil, maxBalance)(nextHandler)
handler.ServeHTTP(rr, req)

// Check response
assert.Equal(t, tt.expectedStatus, rr.Code)
assert.Contains(t, rr.Body.String(), tt.expectedBody)
})
}
}
36 changes: 36 additions & 0 deletions contribs/gnofaucet/cooldown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

import (
"sync"
"time"
)

// CooldownLimiter is a Limiter using an in-memory map
type CooldownLimiter struct {
cooldowns map[string]time.Time
mu sync.Mutex
cooldownTime time.Duration
}

// NewCooldownLimiter initializes a Cooldown Limiter with a given duration
func NewCooldownLimiter(cooldown time.Duration) *CooldownLimiter {
return &CooldownLimiter{
cooldowns: make(map[string]time.Time),
cooldownTime: cooldown,
}
}

// CheckCooldown checks if a key has done some action before the cooldown period has passed
func (rl *CooldownLimiter) CheckCooldown(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()

if lastClaim, found := rl.cooldowns[key]; found {
if time.Since(lastClaim) < rl.cooldownTime {
return false // Deny claim if within cooldown period
}
}

rl.cooldowns[key] = time.Now()
return true
}
28 changes: 28 additions & 0 deletions contribs/gnofaucet/cooldown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestCooldownLimiter(t *testing.T) {
cooldownDuration := time.Second
limiter := NewCooldownLimiter(cooldownDuration)
user := "testUser"

// First check should be allowed
if !limiter.CheckCooldown(user) {
t.Errorf("Expected first CheckCooldown to return true, but got false")
}

// Second check immediately should be denied
if limiter.CheckCooldown(user) {
t.Errorf("Expected second CheckCooldown to return false, but got true")
}

require.Eventually(t, func() bool {
return limiter.CheckCooldown(user)
}, 2*cooldownDuration, 10*time.Millisecond, "Expected CheckCooldown to return true after cooldown period")
}
85 changes: 85 additions & 0 deletions contribs/gnofaucet/gh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"github.com/google/go-github/v64/github"
)

func getGithubMiddleware(clientID, secret string, cooldown time.Duration) func(next http.Handler) http.Handler {
coolDownLimiter := NewCooldownLimiter(cooldown)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// github Oauth flow is enabled
if secret == "" || clientID == "" {
// Continue with serving the faucet request
next.ServeHTTP(w, r)

return
}

code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}

user, err := exchangeCodeForUser(r.Context(), secret, clientID, code)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// Just check if given account have asked for faucet before the cooldown period
if !coolDownLimiter.CheckCooldown(user.GetLogin()) {
http.Error(w, "user is on cooldown", http.StatusTooManyRequests)
return
}

// Possibility to have more conditions like accountAge, commits, pullRequest etc

next.ServeHTTP(w, r)
},
)
}
}

type GitHubTokenResponse struct {
AccessToken string `json:"access_token"`
}

var exchangeCodeForUser = func(ctx context.Context, secret, clientID, code string) (*github.User, error) {
url := "https://github.com/login/oauth/access_token"
body := fmt.Sprintf("client_id=%s&client_secret=%s&code=%s", clientID, secret, code)
req, err := http.NewRequest("POST", url, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var tokenResponse GitHubTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
return nil, err
}

Check warning on line 76 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L57-L76

Added lines #L57 - L76 were not covered by tests

if tokenResponse.AccessToken == "" {
return nil, fmt.Errorf("unable to exchange code for token")
}

Check warning on line 80 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L78-L80

Added lines #L78 - L80 were not covered by tests

ghClient := github.NewClient(http.DefaultClient).WithAuthToken(tokenResponse.AccessToken)
user, _, err := ghClient.Users.Get(ctx, "")
return user, err

Check warning on line 84 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L82-L84

Added lines #L82 - L84 were not covered by tests
}
Loading
Loading