diff --git a/server/v2/api/swagger/config.go b/server/v2/api/swagger/config.go new file mode 100644 index 000000000000..b5efb1f42215 --- /dev/null +++ b/server/v2/api/swagger/config.go @@ -0,0 +1,44 @@ +package swagger + +import ( + "fmt" + "net/http" + + "cosmossdk.io/core/server" +) + +const ServerName = "swagger" + +// Config defines the configuration for the Swagger UI server +type Config struct { + // Enable enables/disables the Swagger UI server + Enable bool `toml:"enable,omitempty" mapstructure:"enable"` + // Address defines the server address to bind to + Address string `toml:"address,omitempty" mapstructure:"address"` + // SwaggerUI defines the file system for serving Swagger UI files + SwaggerUI http.FileSystem `toml:"-" mapstructure:"-"` +} + +// DefaultConfig returns the default configuration +func DefaultConfig() *Config { + return &Config{ + Enable: true, + Address: "localhost:8090", + } +} + +// Validate returns an error if the config is invalid +func (c *Config) Validate() error { + if !c.Enable { + return nil + } + + if c.Address == "" { + return fmt.Errorf("address is required when swagger UI is enabled") + } + + return nil +} + +// CfgOption defines a function for configuring the settings +type CfgOption func(*Config) diff --git a/server/v2/api/swagger/doc.go b/server/v2/api/swagger/doc.go new file mode 100644 index 000000000000..6c3ab9424b5f --- /dev/null +++ b/server/v2/api/swagger/doc.go @@ -0,0 +1,16 @@ +/* +Package swagger provides Swagger UI support for server/v2. + +Example usage in commands.go: + + // Create Swagger server + swaggerServer, err := swaggerv2.New[T]( + logger.With(log.ModuleKey, "swagger"), + deps.GlobalConfig, + ) + +Configuration options: + - enable: Enable/disable the Swagger UI server (default: true) + - address: Server address (default: localhost:8090) +*/ +package swagger diff --git a/server/v2/api/swagger/handler.go b/server/v2/api/swagger/handler.go new file mode 100644 index 000000000000..089c2c8f5f7d --- /dev/null +++ b/server/v2/api/swagger/handler.go @@ -0,0 +1,82 @@ +package swagger + +import ( + "io" + "net/http" + "path/filepath" + "strings" + "time" +) + +type swaggerHandler struct { + swaggerFS http.FileSystem +} + +func (h *swaggerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Set minimal CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET") + + // Add security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'") + + if r.Method == http.MethodOptions { + return + } + + // Process and validate the path + urlPath := strings.TrimPrefix(r.URL.Path, "/swagger") + if urlPath == "" || urlPath == "/" { + urlPath = "/index.html" + } + + // Clean the path before validation + urlPath = filepath.Clean(urlPath) + + // Validate path before any operations + if strings.Contains(urlPath, "..") || strings.Contains(urlPath, "//") || strings.Contains(urlPath, "\\") { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + // Open the file + file, err := h.swaggerFS.Open(urlPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer file.Close() + + // Set the content-type + ext := filepath.Ext(urlPath) + if ct := getContentType(ext); ct != "" { + w.Header().Set("Content-Type", ct) + } + + // Serve the file + http.ServeContent(w, r, urlPath, time.Now(), file.(io.ReadSeeker)) +} + +// getContentType returns the content-type for a file extension +func getContentType(ext string) string { + switch strings.ToLower(ext) { + case ".html": + return "text/html" + case ".css": + return "text/css" + case ".js": + return "application/javascript" + case ".json": + return "application/json" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".svg": + return "image/svg+xml" + default: + return "" + } +} diff --git a/server/v2/api/swagger/server.go b/server/v2/api/swagger/server.go new file mode 100644 index 000000000000..94cbd5fc8be2 --- /dev/null +++ b/server/v2/api/swagger/server.go @@ -0,0 +1,105 @@ +package swagger + +import ( + "context" + "fmt" + "net/http" + + "cosmossdk.io/core/transaction" + "cosmossdk.io/log" + serverv2 "cosmossdk.io/server/v2" +) + +var ( + _ serverv2.ServerComponent[transaction.Tx] = (*Server[transaction.Tx])(nil) + _ serverv2.HasConfig = (*Server[transaction.Tx])(nil) +) + +// Server represents a Swagger UI server +type Server[T transaction.Tx] struct { + logger log.Logger + config *Config + cfgOptions []CfgOption + server *http.Server +} + +// New creates a new Swagger UI server +func New[T transaction.Tx]( + logger log.Logger, + cfg serverv2.ConfigMap, + cfgOptions ...CfgOption, +) (*Server[T], error) { + srv := &Server[T]{ + logger: logger.With(log.ModuleKey, ServerName), + cfgOptions: cfgOptions, + } + + serverCfg := DefaultConfig() + if len(cfg) > 0 { + if err := serverv2.UnmarshalSubConfig(cfg, ServerName, serverCfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + } + for _, opt := range cfgOptions { + opt(serverCfg) + } + srv.config = serverCfg + + if err := srv.config.Validate(); err != nil { + return nil, err + } + + mux := http.NewServeMux() + mux.Handle("/swagger", &swaggerHandler{ + swaggerFS: srv.config.SwaggerUI, + }) + + srv.server = &http.Server{ + Addr: srv.config.Address, + Handler: mux, + } + + return srv, nil +} + +// Name returns the server's name +func (s *Server[T]) Name() string { + return ServerName +} + +// Config returns the server configuration +func (s *Server[T]) Config() any { + if s.config == nil { + cfg := DefaultConfig() + for _, opt := range s.cfgOptions { + opt(cfg) + } + return cfg + } + return s.config +} + +// Start starts the server +func (s *Server[T]) Start(ctx context.Context) error { + if !s.config.Enable { + s.logger.Info(fmt.Sprintf("%s server is disabled via config", s.Name())) + return nil + } + + s.logger.Info("starting swagger server...", "address", s.config.Address) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("failed to start swagger server: %w", err) + } + + return nil +} + +// Stop stops the server +func (s *Server[T]) Stop(ctx context.Context) error { + if !s.config.Enable { + return nil + } + + s.logger.Info("stopping swagger server...", "address", s.config.Address) + return s.server.Shutdown(ctx) +} diff --git a/simapp/v2/app.go b/simapp/v2/app.go index d32c76f9a687..3d1c51d24fe9 100644 --- a/simapp/v2/app.go +++ b/simapp/v2/app.go @@ -186,6 +186,7 @@ func NewSimApp[T transaction.Tx]( if err = app.LoadLatest(); err != nil { return nil, err } + return app, nil } diff --git a/simapp/v2/simdv2/cmd/commands.go b/simapp/v2/simdv2/cmd/commands.go index f9b9e6ebfc09..2f0e52554b3d 100644 --- a/simapp/v2/simdv2/cmd/commands.go +++ b/simapp/v2/simdv2/cmd/commands.go @@ -15,6 +15,7 @@ import ( grpcserver "cosmossdk.io/server/v2/api/grpc" "cosmossdk.io/server/v2/api/grpcgateway" "cosmossdk.io/server/v2/api/rest" + swaggerv2 "cosmossdk.io/server/v2/api/swagger" "cosmossdk.io/server/v2/api/telemetry" "cosmossdk.io/server/v2/cometbft" serverstore "cosmossdk.io/server/v2/store" @@ -91,6 +92,7 @@ func InitRootCmd[T transaction.Tx]( &telemetry.Server[T]{}, &rest.Server[T]{}, &grpcgateway.Server[T]{}, + &swaggerv2.Server[T]{}, ) } @@ -163,6 +165,15 @@ func InitRootCmd[T transaction.Tx]( } registerGRPCGatewayRoutes[T](deps, grpcgatewayServer) + // Create Swagger server + swaggerServer, err := swaggerv2.New[T]( + logger.With(log.ModuleKey, "swagger"), + deps.GlobalConfig, + ) + if err != nil { + return nil, err + } + // wire server commands return serverv2.AddCommands[T]( rootCmd, @@ -176,6 +187,7 @@ func InitRootCmd[T transaction.Tx]( telemetryServer, restServer, grpcgatewayServer, + swaggerServer, ) }