- 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
216 lines
4.8 KiB
Go
216 lines
4.8 KiB
Go
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
|
|
type Gateway struct {
|
|
apiURL *url.URL
|
|
rateLimiter *RateLimiter
|
|
auth *AuthMiddleware
|
|
}
|
|
|
|
// NewGateway creates a new API gateway
|
|
func NewGateway(apiURL string) (*Gateway, error) {
|
|
parsedURL, err := url.Parse(apiURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid API URL: %w", err)
|
|
}
|
|
|
|
return &Gateway{
|
|
apiURL: parsedURL,
|
|
rateLimiter: NewRateLimiter(),
|
|
auth: NewAuthMiddleware(),
|
|
}, nil
|
|
}
|
|
|
|
// Start starts the gateway server
|
|
func (g *Gateway) Start(port int) error {
|
|
mux := http.NewServeMux()
|
|
|
|
// Proxy to API server
|
|
proxy := httputil.NewSingleHostReverseProxy(g.apiURL)
|
|
|
|
mux.HandleFunc("/", g.handleRequest(proxy))
|
|
|
|
addr := fmt.Sprintf(":%d", port)
|
|
log.Printf("Starting API Gateway on %s", addr)
|
|
return http.ListenAndServe(addr, mux)
|
|
}
|
|
|
|
// handleRequest handles incoming requests with middleware
|
|
func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Add security headers
|
|
g.addSecurityHeaders(w)
|
|
|
|
// Authentication
|
|
if !g.auth.Authenticate(r) {
|
|
httperrors.WriteJSON(w, http.StatusUnauthorized, "UNAUTHORIZED", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
// Rate limiting
|
|
if !g.rateLimiter.Allow(r) {
|
|
httperrors.WriteJSON(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
// Add headers
|
|
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)
|
|
}
|
|
|
|
// Add branding header
|
|
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
|
|
w.Header().Set("X-Explorer-Version", "1.0.0")
|
|
|
|
// Proxy request
|
|
proxy.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// addSecurityHeaders adds security headers to responses
|
|
func (g *Gateway) addSecurityHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
// CSP will be set per route if needed
|
|
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
}
|
|
|
|
// 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 time.Time
|
|
}
|
|
|
|
const gatewayRequestsPerMinute = 120
|
|
|
|
func NewRateLimiter() *RateLimiter {
|
|
return &RateLimiter{
|
|
limits: make(map[string]*limitEntry),
|
|
}
|
|
}
|
|
|
|
func (rl *RateLimiter) Allow(r *http.Request) bool {
|
|
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 {
|
|
allowAnonymous bool
|
|
apiKeys []string
|
|
}
|
|
|
|
func NewAuthMiddleware() *AuthMiddleware {
|
|
return &AuthMiddleware{
|
|
allowAnonymous: parseBoolEnv("GATEWAY_ALLOW_ANONYMOUS"),
|
|
apiKeys: splitNonEmptyEnv("GATEWAY_API_KEYS"),
|
|
}
|
|
}
|
|
|
|
func (am *AuthMiddleware) Authenticate(r *http.Request) bool {
|
|
apiKey := am.GetAPIKey(r)
|
|
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 {
|
|
// Check header first
|
|
if key := r.Header.Get("X-API-Key"); key != "" {
|
|
return key
|
|
}
|
|
// Check query parameter
|
|
if key := r.URL.Query().Get("api_key"); key != "" {
|
|
return key
|
|
}
|
|
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
|
|
}
|