feat: explorer API, wallet, CCIP scripts, and config refresh
- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
httperrors "github.com/explorer/backend/libs/go-http-errors"
|
||||
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
||||
)
|
||||
|
||||
// Gateway represents the API gateway
|
||||
@@ -64,7 +70,9 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Add headers
|
||||
r.Header.Set("X-Forwarded-For", r.RemoteAddr)
|
||||
if clientIP := httpmiddleware.ClientIP(r); clientIP != "" {
|
||||
r.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
if apiKey := g.auth.GetAPIKey(r); apiKey != "" {
|
||||
r.Header.Set("X-API-Key", apiKey)
|
||||
}
|
||||
@@ -92,14 +100,17 @@ func (g *Gateway) addSecurityHeaders(w http.ResponseWriter) {
|
||||
// RateLimiter handles rate limiting
|
||||
type RateLimiter struct {
|
||||
// Simple in-memory rate limiter (should use Redis in production)
|
||||
mu sync.Mutex
|
||||
limits map[string]*limitEntry
|
||||
}
|
||||
|
||||
type limitEntry struct {
|
||||
count int
|
||||
resetAt int64
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
const gatewayRequestsPerMinute = 120
|
||||
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
return &RateLimiter{
|
||||
limits: make(map[string]*limitEntry),
|
||||
@@ -107,26 +118,62 @@ func NewRateLimiter() *RateLimiter {
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(r *http.Request) bool {
|
||||
_ = r.RemoteAddr // Will be used in production for per-IP limiting
|
||||
// In production, use Redis with token bucket algorithm
|
||||
// For now, simple per-IP limiting
|
||||
return true // Simplified - implement proper rate limiting
|
||||
clientIP := httpmiddleware.ClientIP(r)
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
entry, ok := rl.limits[clientIP]
|
||||
if !ok || now.After(entry.resetAt) {
|
||||
rl.limits[clientIP] = &limitEntry{
|
||||
count: 1,
|
||||
resetAt: now.Add(time.Minute),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if entry.count >= gatewayRequestsPerMinute {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthMiddleware handles authentication
|
||||
type AuthMiddleware struct {
|
||||
// In production, validate against database
|
||||
allowAnonymous bool
|
||||
apiKeys []string
|
||||
}
|
||||
|
||||
func NewAuthMiddleware() *AuthMiddleware {
|
||||
return &AuthMiddleware{}
|
||||
return &AuthMiddleware{
|
||||
allowAnonymous: parseBoolEnv("GATEWAY_ALLOW_ANONYMOUS"),
|
||||
apiKeys: splitNonEmptyEnv("GATEWAY_API_KEYS"),
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) Authenticate(r *http.Request) bool {
|
||||
// Allow anonymous access for now
|
||||
// In production, validate API key
|
||||
apiKey := am.GetAPIKey(r)
|
||||
return apiKey != "" || true // Allow anonymous for MVP
|
||||
if apiKey == "" {
|
||||
return am.allowAnonymous
|
||||
}
|
||||
if len(am.apiKeys) == 0 {
|
||||
return am.allowAnonymous
|
||||
}
|
||||
|
||||
for _, allowedKey := range am.apiKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(allowedKey)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
|
||||
@@ -140,3 +187,29 @@ func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolEnv(key string) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
return strings.EqualFold(value, "1") ||
|
||||
strings.EqualFold(value, "true") ||
|
||||
strings.EqualFold(value, "yes") ||
|
||||
strings.EqualFold(value, "on")
|
||||
}
|
||||
|
||||
func splitNonEmptyEnv(key string) []string {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
78
backend/api/gateway/gateway_test.go
Normal file
78
backend/api/gateway/gateway_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMiddlewareRejectsAnonymousByDefault(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "")
|
||||
t.Setenv("GATEWAY_API_KEYS", "")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
|
||||
if auth.Authenticate(req) {
|
||||
t.Fatal("expected anonymous request to be rejected by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAllowsConfiguredAPIKey(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "")
|
||||
t.Setenv("GATEWAY_API_KEYS", "alpha,beta")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.Header.Set("X-API-Key", "beta")
|
||||
|
||||
if !auth.Authenticate(req) {
|
||||
t.Fatal("expected configured API key to be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAllowsAnonymousOnlyWhenEnabled(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "true")
|
||||
t.Setenv("GATEWAY_API_KEYS", "")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
|
||||
if !auth.Authenticate(req) {
|
||||
t.Fatal("expected anonymous request to be accepted when explicitly enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterBlocksAfterWindowBudget(t *testing.T) {
|
||||
limiter := NewRateLimiter()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.RemoteAddr = "203.0.113.10:1234"
|
||||
|
||||
for i := 0; i < gatewayRequestsPerMinute; i++ {
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatalf("expected request %d to pass", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if limiter.Allow(req) {
|
||||
t.Fatal("expected request over the per-minute budget to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterResetsAfterWindow(t *testing.T) {
|
||||
limiter := NewRateLimiter()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.RemoteAddr = "203.0.113.11:1234"
|
||||
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatal("expected first request to pass")
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
limiter.limits["203.0.113.11"].resetAt = time.Now().Add(-time.Second)
|
||||
limiter.mu.Unlock()
|
||||
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatal("expected limiter window to reset")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user