- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
267 lines
7.4 KiB
Go
267 lines
7.4 KiB
Go
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
|
|
}
|
|
|
|
// NewServer creates a new REST API server
|
|
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
|
// Get JWT secret from environment or generate an ephemeral secret.
|
|
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
|
|
if len(jwtSecret) == 0 {
|
|
jwtSecret = generateEphemeralJWTSecret()
|
|
log.Println("WARNING: JWT_SECRET is unset. Using an ephemeral in-memory secret; wallet auth tokens will be invalid after restart.")
|
|
}
|
|
|
|
walletAuth := auth.NewWalletAuth(db, jwtSecret)
|
|
|
|
return &Server{
|
|
db: db,
|
|
chainID: chainID,
|
|
userAuth: auth.NewAuth(db),
|
|
walletAuth: walletAuth,
|
|
jwtSecret: jwtSecret,
|
|
aiLimiter: NewAIRateLimiter(),
|
|
aiMetrics: NewAIMetrics(),
|
|
}
|
|
}
|
|
|
|
func generateEphemeralJWTSecret() []byte {
|
|
secret := make([]byte, 32)
|
|
if _, err := rand.Read(secret); err == nil {
|
|
return secret
|
|
}
|
|
|
|
fallback := []byte(fmt.Sprintf("ephemeral-jwt-secret-%d", time.Now().UnixNano()))
|
|
log.Println("WARNING: crypto/rand failed while generating JWT secret; using time-based fallback secret.")
|
|
return fallback
|
|
}
|
|
|
|
// 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://blockscout.defi-oracle.io 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 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)
|
|
}
|