253 lines
6.9 KiB
Go
253 lines
6.9 KiB
Go
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)
|
|
}
|