Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c8a8456
feat: faucet github middleware with coolDown
Villaquiranm Feb 22, 2025
dd50e7b
add max balance middleware and tests
Feb 23, 2025
b054763
apply lint
Feb 23, 2025
118c08b
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Feb 24, 2025
07f6145
generalize cooldown limiter
Feb 24, 2025
ab43999
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 17, 2025
962ac2c
add some comments :)
Mar 17, 2025
e59b6a7
Update contribs/gnofaucet/gh.go
Villaquiranm Mar 17, 2025
a55637c
Update contribs/gnofaucet/serve.go
Villaquiranm Mar 17, 2025
6e9727a
add badger as database
Mar 19, 2025
62d710a
fix lint
Mar 19, 2025
6bab9b2
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 19, 2025
32d6d24
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
bbd82c0
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
64534bd
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
dede43c
fix comments :)
Mar 20, 2025
022e923
return error on cooldown limiter
Mar 20, 2025
373f6e0
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 20, 2025
e5a461d
oups typo
Mar 20, 2025
439b1b7
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 26, 2025
5a1dd62
change badger -> redis
Mar 31, 2025
72c7f4d
fixes
Apr 3, 2025
f984a7b
separate cmd for github and captcha
Apr 3, 2025
28182f6
remove check balance on account
Apr 3, 2025
703b16f
implement total claimable limit
Apr 3, 2025
426919a
improve tests
Apr 3, 2025
300b7f4
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Apr 3, 2025
909ad71
fix lint
Apr 3, 2025
82c3600
fix comments
Villaquiranm Apr 4, 2025
cb07372
separate test in different files
Villaquiranm Apr 4, 2025
a4b8827
update Readme
Villaquiranm Apr 4, 2025
1415777
document redis ?
Villaquiranm Apr 4, 2025
3ba49d9
take ugnots from string
Villaquiranm Apr 6, 2025
c9f2535
fix test and change default listen address
Villaquiranm Apr 7, 2025
360317f
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Apr 7, 2025
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