Files
explorer-monorepo/backend/api/rest/server.go
defiQUG a53c15507f fix: API JSON error responses + navbar with dropdowns
- Add backend/libs/go-http-errors for consistent JSON errors
- REST API: use writeMethodNotAllowed, writeNotFound, writeInternalError
- middleware, gateway, search: use httperrors.WriteJSON
- SPA: navbar with Explore/Tools/More dropdowns, initNavDropdowns()
- Next.js: Navbar component with dropdowns + mobile menu

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 03:09:53 -08:00

249 lines
6.8 KiB
Go

package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/explorer/backend/auth"
"github.com/explorer/backend/api/middleware"
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
}
// 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,
}
}
// 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, &timestamp, &timestampISO, &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)
}