Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 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
7 changes: 7 additions & 0 deletions contribs/gnofaucet/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# required
# These credentials are obtained when registering an application on GitHub at:
# https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app#registering-your-app
GH_CLIENT_SECRET=
REDIS_ADDR=
REDIS_USER=
REDIS_PASSWORD=
49 changes: 49 additions & 0 deletions contribs/gnofaucet/captcha.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"context"
"flag"
"fmt"

"github.com/gnolang/gno/tm2/pkg/commands"
)

type captchaCfg struct {
rootCfg *serveCfg
captchaSecret string
}

func (c *captchaCfg) RegisterFlags(fs *flag.FlagSet) {
fs.StringVar(
&c.captchaSecret,
"captcha-secret",
"",
"recaptcha secret key (if empty, captcha are disabled)",
)
}

func newCaptchaCmd(rootCfg *serveCfg) *commands.Command {
cfg := &captchaCfg{
rootCfg: rootCfg,
}

return commands.NewCommand(
commands.Metadata{
Name: "captcha",
ShortUsage: "captcha [flags]",
LongHelp: "applies captcha middleware to the gno.land faucet",
},
cfg,
func(ctx context.Context, args []string) error {
return execCaptcha(ctx, cfg, commands.NewDefaultIO())
},
)
}

func execCaptcha(ctx context.Context, cfg *captchaCfg, io commands.IO) error {
if cfg.captchaSecret == "" {
return fmt.Errorf("captcha secret is required")
}

return serveFaucet(ctx, cfg.rootCfg, io, getCaptchaMiddleware(cfg.captchaSecret))
}
83 changes: 83 additions & 0 deletions contribs/gnofaucet/cooldown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)

// CooldownLimiter limits a specific user to one claim per cooldown period
// this limiter keeps track of which keys are on cooldown using a badger database (written to a local file)
type CooldownLimiter struct {
redis *redis.Client
cooldownTime time.Duration
maxlifeTimeAmount int64
}

// NewCooldownLimiter initializes a Cooldown Limiter with a given duration
func NewCooldownLimiter(cooldown time.Duration, redis *redis.Client, maxlifeTimeAmount int64) *CooldownLimiter {
return &CooldownLimiter{
redis: redis,
cooldownTime: cooldown,
maxlifeTimeAmount: maxlifeTimeAmount,
}
}

// CheckCooldown checks if a key can make a claim or if it is still within the cooldown period
// also checks that the user will not exceed the max lifetime allowed amount
// Returns true if the key is not on cooldown, and marks the key as on cooldown
// Returns false if the key is on cooldown or if an error occurs
func (rl *CooldownLimiter) CheckCooldown(ctx context.Context, key string, amountClaimed int64) (bool, error) {
claimData, err := rl.getClaimsData(ctx, key)
if err != nil {
return false, fmt.Errorf("unable to check if key is on cooldown, %w", err)
}

Check warning on line 38 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L37-L38

Added lines #L37 - L38 were not covered by tests
// Deny claim if within cooldown period
if claimData.LastClaimed.Add(rl.cooldownTime).After(time.Now()) {
return false, nil
}
// check that user will not exceed max lifetime allowed amount
if claimData.TotalClaimed+amountClaimed > rl.maxlifeTimeAmount {
return false, nil
}

return true, rl.declareClaimedValue(ctx, key, amountClaimed, claimData)
}

func (rl *CooldownLimiter) getClaimsData(ctx context.Context, key string) (*claimData, error) {
storedData, err := rl.redis.Get(ctx, key).Result()
if err != nil {
// Here we return an empty claimData because is the first time the user is making a claim
// the total amount claimed is 0 and the lastClaimed is the default time value
if errors.Is(err, redis.Nil) {
return &claimData{}, nil
}
// Any other unexpected error is returned.
return nil, err

Check warning on line 60 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L60

Added line #L60 was not covered by tests
}

claimData := &claimData{}
err = json.Unmarshal([]byte(storedData), claimData)
return claimData, err
}

func (rl *CooldownLimiter) declareClaimedValue(ctx context.Context, key string, amountClaimed int64, currentData *claimData) error {
currentData.LastClaimed = time.Now()
currentData.TotalClaimed += amountClaimed

data, err := json.Marshal(currentData)
if err != nil {
return fmt.Errorf("unable to marshal claim data, %w", err)
}

Check warning on line 75 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L74-L75

Added lines #L74 - L75 were not covered by tests

return rl.redis.Set(ctx, key, data, 0).Err()
}

type claimData struct {
LastClaimed time.Time
TotalClaimed int64
}
45 changes: 45 additions & 0 deletions contribs/gnofaucet/cooldown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"context"
"math"
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)

func TestCooldownLimiter(t *testing.T) {
var tenGnots int64 = 10_000_000
redisServer := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{
Addr: redisServer.Addr(),
})

cooldownDuration := time.Second
limiter := NewCooldownLimiter(cooldownDuration, rdb, math.MaxInt64)
ctx := context.Background()
user := "testUser"

// First check should be allowed
allowed, err := limiter.CheckCooldown(ctx, user, tenGnots)
require.NoError(t, err)

if !allowed {
t.Errorf("Expected first CheckCooldown to return true, but got false")
}

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

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

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

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

// getGithubMiddleware sets up authentication middleware for GitHub OAuth.
// If clientID and secret are empty, the middleware does nothing.
//
// Parameters:
// - clientID: The OAuth client ID issued by GitHub when registering the application.
// - secret: The OAuth client secret used to securely authenticate API requests.
// - cooldown: A cooldown duration to prevent several claims from the same user.
//
// GitHub OAuth applications require a client ID and secret to authenticate users securely.
// These credentials are obtained when registering an application on GitHub at:
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app#registering-your-app
func getGithubMiddleware(clientID, secret string, coolDownLimiter *CooldownLimiter) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// Extracts the authorization code returned by the GitHub OAuth flow.
//
// When a user successfully authenticates via GitHub OAuth, GitHub redirects them
// to the registered callback URL with a `code` query parameter. This code is then
// exchanged for an access token.
//
// Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github
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
}

claimAmount, err := getClaimAmount(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

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

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L51-L53

Added lines #L51 - L53 were not covered by tests

// Just check if given account have asked for faucet before the cooldown period
allowedToClaim, err := coolDownLimiter.CheckCooldown(r.Context(), user.GetLogin(), claimAmount)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

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

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L58-L60

Added lines #L58 - L60 were not covered by tests

if !allowedToClaim {
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 request struct {
Amount int64 `json:"amount"`
}

func getClaimAmount(r *http.Request) (int64, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return 0, err
}

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

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L81-L82

Added lines #L81 - L82 were not covered by tests

var data request
err = json.Unmarshal(body, &data)
if err != nil {
return 0, err
}

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

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L87-L88

Added lines #L87 - L88 were not covered by tests
r.Body = io.NopCloser(bytes.NewBuffer(body))
return data.Amount, nil
}

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

//nolint:gosec
const githubTokenExchangeURL = "https://github.com/login/oauth/access_token"

var exchangeCodeForUser = func(ctx context.Context, secret, clientID, code string) (*github.User, error) {
body := fmt.Sprintf("client_id=%s&client_secret=%s&code=%s", clientID, secret, code)
req, err := http.NewRequest("POST", githubTokenExchangeURL, 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 118 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L100-L118

Added lines #L100 - L118 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L120-L122

Added lines #L120 - L122 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 126 in contribs/gnofaucet/gh.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/gh.go#L124-L126

Added lines #L124 - L126 were not covered by tests
}
Loading
Loading