package rest import ( "context" "crypto/rand" "database/sql" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "github.com/explorer/backend/api/middleware" "github.com/explorer/backend/auth" httpmiddleware "github.com/explorer/backend/libs/go-http-middleware" "github.com/jackc/pgx/v5/pgxpool" ) // Server represents the REST API server type Server struct { db *pgxpool.Pool chainID int userAuth *auth.Auth walletAuth *auth.WalletAuth jwtSecret []byte aiLimiter *AIRateLimiter aiMetrics *AIMetrics } // minJWTSecretBytes is the minimum allowed length for an operator-provided // JWT signing secret. 32 random bytes = 256 bits, matching HS256's output. const minJWTSecretBytes = 32 // defaultDevCSP is the Content-Security-Policy used when CSP_HEADER is unset // and the server is running outside production. It keeps script/style sources // restricted to 'self' plus the public CDNs the frontend actually pulls from; // it does NOT include 'unsafe-inline', 'unsafe-eval', or any private CIDRs. // Production deployments MUST provide an explicit CSP_HEADER. const defaultDevCSP = "default-src 'self'; " + "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; " + "style-src 'self' https://cdnjs.cloudflare.com; " + "font-src 'self' https://cdnjs.cloudflare.com; " + "img-src 'self' data: https:; " + "connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self';" // isProductionEnv reports whether the server is running in production mode. // Production is signalled by APP_ENV=production or GO_ENV=production. func isProductionEnv() bool { for _, key := range []string{"APP_ENV", "GO_ENV"} { if strings.EqualFold(strings.TrimSpace(os.Getenv(key)), "production") { return true } } return false } // NewServer creates a new REST API server. // // Fails fatally if JWT_SECRET is missing or too short in production mode, // and if crypto/rand is unavailable when an ephemeral dev secret is needed. func NewServer(db *pgxpool.Pool, chainID int) *Server { jwtSecret := loadJWTSecret() walletAuth := auth.NewWalletAuth(db, jwtSecret) return &Server{ db: db, chainID: chainID, userAuth: auth.NewAuth(db), walletAuth: walletAuth, jwtSecret: jwtSecret, aiLimiter: NewAIRateLimiter(), aiMetrics: NewAIMetrics(), } } // loadJWTSecret reads the signing secret from $JWT_SECRET. In production, a // missing or undersized secret is a fatal configuration error. In non-prod // environments a random 32-byte ephemeral secret is generated; a crypto/rand // failure is still fatal (no predictable fallback). func loadJWTSecret() []byte { raw := strings.TrimSpace(os.Getenv("JWT_SECRET")) if raw != "" { if len(raw) < minJWTSecretBytes { log.Fatalf("JWT_SECRET must be at least %d bytes (got %d); refusing to start with a weak signing key", minJWTSecretBytes, len(raw)) } return []byte(raw) } if isProductionEnv() { log.Fatal("JWT_SECRET is required in production (APP_ENV=production or GO_ENV=production); refusing to start") } secret := make([]byte, minJWTSecretBytes) if _, err := rand.Read(secret); err != nil { log.Fatalf("failed to generate ephemeral JWT secret: %v", err) } log.Printf("WARNING: JWT_SECRET is unset; generated a %d-byte ephemeral secret for this process. "+ "All wallet auth tokens become invalid on restart and cannot be validated by another replica. "+ "Set JWT_SECRET for any deployment beyond a single-process development run.", minJWTSecretBytes) return secret } // Start starts the HTTP server func (s *Server) Start(port int) error { mux := http.NewServeMux() s.SetupRoutes(mux) // Initialize auth middleware authMiddleware := middleware.NewAuthMiddleware(s.walletAuth) // Setup track routes with proper middleware s.SetupTrackRoutes(mux, authMiddleware) // Security headers. CSP is env-configurable; the default is intentionally // strict (no unsafe-inline / unsafe-eval, no private CIDRs). Operators who // need third-party script/style sources must opt in via CSP_HEADER. csp := strings.TrimSpace(os.Getenv("CSP_HEADER")) if csp == "" { if isProductionEnv() { log.Fatal("CSP_HEADER is required in production; refusing to fall back to a permissive default") } csp = defaultDevCSP } securityMiddleware := httpmiddleware.NewSecurity(csp) // Add middleware for all routes (outermost to innermost) handler := securityMiddleware.AddSecurityHeaders( authMiddleware.OptionalAuth( // Optional auth for Track 1, required for others s.addMiddleware( s.loggingMiddleware( s.compressionMiddleware(mux), ), ), ), ) addr := fmt.Sprintf(":%d", port) log.Printf("Starting SolaceScan REST API server on %s", addr) log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)") return http.ListenAndServe(addr, handler) } // addMiddleware adds common middleware to all routes func (s *Server) addMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add branding headers w.Header().Set("X-Explorer-Name", "SolaceScan") w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Powered-By", "SolaceScan") // Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://blockscout.defi-oracle.io) if strings.HasPrefix(r.URL.Path, "/api/") { origin := os.Getenv("CORS_ALLOWED_ORIGIN") if origin == "" { origin = "*" } w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key") // Handle preflight if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } } next.ServeHTTP(w, r) }) } // requireDB returns false and writes 503 if db is nil (e.g. in tests without DB) func (s *Server) requireDB(w http.ResponseWriter) bool { if s.db == nil { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database unavailable") return false } return true } // handleListBlocks handles GET /api/v1/blocks func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w) return } if !s.requireDB(w) { return } // Validate pagination page, pageSize, err := validatePagination( r.URL.Query().Get("page"), r.URL.Query().Get("page_size"), ) if err != nil { writeValidationError(w, err) return } offset := (page - 1) * pageSize query := ` SELECT chain_id, number, hash, timestamp, timestamp_iso, miner, transaction_count, gas_used, gas_limit FROM blocks WHERE chain_id = $1 ORDER BY number DESC LIMIT $2 OFFSET $3 ` // Add query timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() rows, err := s.db.Query(ctx, query, s.chainID, pageSize, offset) if err != nil { writeInternalError(w, "Database error") return } defer rows.Close() blocks := []map[string]interface{}{} for rows.Next() { var chainID, number, transactionCount int var hash, miner string var timestamp time.Time var timestampISO sql.NullString var gasUsed, gasLimit int64 if err := rows.Scan(&chainID, &number, &hash, ×tamp, ×tampISO, &miner, &transactionCount, &gasUsed, &gasLimit); err != nil { continue } block := map[string]interface{}{ "chain_id": chainID, "number": number, "hash": hash, "timestamp": timestamp, "miner": miner, "transaction_count": transactionCount, "gas_used": gasUsed, "gas_limit": gasLimit, } if timestampISO.Valid { block["timestamp_iso"] = timestampISO.String } blocks = append(blocks, block) } response := map[string]interface{}{ "data": blocks, "meta": map[string]interface{}{ "pagination": map[string]interface{}{ "page": page, "page_size": pageSize, }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // handleGetBlock, handleListTransactions, handleGetTransaction, handleGetAddress // are implemented in blocks.go, transactions.go, and addresses.go respectively // handleHealth handles GET /health func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Explorer-Name", "SolaceScan") w.Header().Set("X-Explorer-Version", "1.0.0") // Check database connection dbStatus := "ok" if s.db != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := s.db.Ping(ctx); err != nil { dbStatus = "error: " + err.Error() } } else { dbStatus = "unavailable" } health := map[string]interface{}{ "status": "healthy", "timestamp": time.Now().UTC().Format(time.RFC3339), "services": map[string]string{ "database": dbStatus, "api": "ok", }, "chain_id": s.chainID, "explorer": map[string]string{ "name": "SolaceScan", "version": "1.0.0", }, } statusCode := http.StatusOK if dbStatus != "ok" { statusCode = http.StatusServiceUnavailable health["status"] = "degraded" } w.WriteHeader(statusCode) json.NewEncoder(w).Encode(health) }