package rest import ( "context" "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 walletAuth *auth.WalletAuth jwtSecret []byte aiLimiter *AIRateLimiter aiMetrics *AIMetrics } // NewServer creates a new REST API server func NewServer(db *pgxpool.Pool, chainID int) *Server { // Get JWT secret from environment or use default jwtSecret := []byte(os.Getenv("JWT_SECRET")) if len(jwtSecret) == 0 { jwtSecret = []byte("change-me-in-production-use-strong-random-secret") log.Println("WARNING: Using default JWT secret. Set JWT_SECRET environment variable in production!") } walletAuth := auth.NewWalletAuth(db, jwtSecret) return &Server{ db: db, chainID: chainID, walletAuth: walletAuth, jwtSecret: jwtSecret, aiLimiter: NewAIRateLimiter(), aiMetrics: NewAIMetrics(), } } // 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 (reusable lib; CSP from env or explorer default) csp := os.Getenv("CSP_HEADER") if csp == "" { csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;" } 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 SolaceScanScout 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", "SolaceScanScout") w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Powered-By", "SolaceScanScout") // Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://explorer.d-bis.org) 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", "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", "SolaceScanScout") 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": "SolaceScanScout", "version": "1.0.0", }, } statusCode := http.StatusOK if dbStatus != "ok" { statusCode = http.StatusServiceUnavailable health["status"] = "degraded" } w.WriteHeader(statusCode) json.NewEncoder(w).Encode(health) }