backend/server/echo_routes.go
Rama main · réplica de lectura del repositorio original.
package server
import (
"log/slog"
"net/http"
"strings"
"github.com/labstack/echo-contrib/v5/echoprometheus"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
connectcors "connectrpc.com/cors"
directorysync "github.com/bytebase/bytebase/backend/api/directory-sync"
"github.com/bytebase/bytebase/backend/api/lsp"
"github.com/bytebase/bytebase/backend/api/mcp"
"github.com/bytebase/bytebase/backend/api/oauth2"
stripeapi "github.com/bytebase/bytebase/backend/api/stripe"
"github.com/bytebase/bytebase/backend/common"
"github.com/bytebase/bytebase/backend/common/log"
"github.com/bytebase/bytebase/backend/component/config"
"github.com/bytebase/bytebase/backend/component/productmetrics"
stripeplugin "github.com/bytebase/bytebase/backend/plugin/stripe"
)
func configureEchoRouters(
e *echo.Echo,
lspServer *lsp.Server,
directorySyncServer *directorysync.Service,
oauth2Service *oauth2.Service,
mcpServer *mcp.Server,
stripeWebhookHandler *stripeapi.WebhookHandler,
productMetrics *productmetrics.ProductMetrics,
profile *config.Profile,
) {
e.Use(recoverMiddleware)
e.Use(securityHeadersMiddleware(profile.SaaS))
if profile.Mode == common.ReleaseModeDev {
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
UnsafeAllowOriginFunc: func(_ *echo.Context, origin string) (string, bool, error) {
return origin, true, nil
},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodOptions},
AllowHeaders: connectcors.AllowedHeaders(),
ExposeHeaders: connectcors.ExposedHeaders(),
AllowCredentials: true,
}))
}
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogURI: true,
LogMethod: true,
LogStatus: true,
LogValuesFunc: func(_ *echo.Context, values middleware.RequestLoggerValues) error {
if values.Error != nil {
slog.Error("echo request logger", "method", values.Method, "uri", values.URI, "status", values.Status, log.BBError(values.Error))
}
return nil
},
}))
registerPprof(e, &profile.RuntimeDebug)
registerMetricsRoute(e, profile, productMetrics)
e.GET("/healthz", func(c *echo.Context) error {
return c.String(http.StatusOK, "OK")
})
// LSP server.
e.GET(lspAPI, lspServer.Router)
hookGroup := e.Group(webhookAPIPrefix)
scimGroup := hookGroup.Group(scimAPIPrefix)
directorySyncServer.RegisterDirectorySyncRoutes(scimGroup)
// Stripe (SaaS only, requires both API key and webhook secret).
if profile.SaaS && profile.StripeAPISecret != "" && profile.StripeWebhookSecret != "" {
stripeplugin.Init(profile.StripeAPISecret)
stripeGroup := hookGroup.Group("/stripe")
stripeWebhookHandler.RegisterRoutes(stripeGroup)
}
// OAuth2 server.
oauth2Service.RegisterRoutes(e)
// MCP server.
mcpServer.RegisterRoutes(e)
// Embed frontend (must be last to serve as fallback for SPA routes).
embedFrontend(e)
}
func registerMetricsRoute(e *echo.Echo, profile *config.Profile, productMetrics *productmetrics.ProductMetrics) {
// Prometheus metrics - use custom registry to avoid duplicate registration in tests
registry := prometheus.NewRegistry()
e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{
Subsystem: "api",
Registerer: registry,
}))
// Fold the local echo registry with the default registry at scrape
// time. The local registry isolates echo HTTP middleware metrics from
// duplicate-registration errors in tests; the default registry catches
// promauto-registered metrics from other packages (e.g. db_metrics,
// the tidb dispatcher fallback counter, and Go runtime metrics auto-
// registered by client_golang). Without this fold, those metrics are
// registered but never exposed at /metrics.
//
// Why bypass echoprometheus.NewHandlerWithConfig: that helper only
// applies promhttp.InstrumentMetricHandler when its Gatherer also
// implements prometheus.Registerer (echoprometheus/prometheus.go:129).
// prometheus.Gatherers (slice type) does not implement Registerer,
// so passing the fold there silently drops scrape-health
// self-instrumentation (promhttp_metric_handler_requests_total etc.).
// Use promhttp directly: pass the local registry as the Registerer
// for self-instrumentation; pass the Gatherers fold as the gather
// source. Both observability surfaces preserved.
registry.MustRegister(productMetrics)
e.GET("/metrics", echo.WrapHandler(metricsAccessHandler(profile.MetricsRemoteAccess, newMetricsHandler(registry))))
}
func recoverMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = errors.Errorf("%v", r)
}
slog.Error("Middleware PANIC RECOVER", log.BBError(err), log.BBStack("panic-stack"))
// In Echo v5, send error response directly
resp, unwrapErr := echo.UnwrapResponse(c.Response())
if unwrapErr == nil && !resp.Committed {
_ = c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Internal server error",
})
}
}
}()
return next(c)
}
}
func securityHeadersMiddleware(saas bool) echo.MiddlewareFunc {
// Content Security Policy
// Note: style-src allows 'unsafe-inline' temporarily due to inline styles in Vue components
// TODO: Migrate inline styles to CSS classes and remove 'unsafe-inline'
// Note: script-src uses SHA-256 hashes for @vitejs/plugin-legacy inline scripts
// Hashes are loaded from dist/csp-hashes.json (generated by frontend build)
// Note: script-src allows 'wasm-unsafe-eval' for Monaco Editor WebAssembly modules
// Monaco's TextMate service embeds WASM that requires runtime compilation
// Note: connect-src allows 'data:' for Monaco Editor language definitions
scriptHashes := loadCSPHashes()
scriptSrc := "script-src 'self' " + strings.Join(scriptHashes, " ") + " 'wasm-unsafe-eval'"
imgSrc := "img-src 'self' data: blob: discordapp.com"
connectSrc := "connect-src 'self' data: ws: wss: https://api.github.com https://hub.bytebase.com"
if saas {
scriptSrc += " https://www.googletagmanager.com https://*.i.posthog.com"
imgSrc += " https://*.google-analytics.com https://*.googletagmanager.com"
connectSrc += " https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://*.i.posthog.com"
}
csp := "default-src 'self'; " +
scriptSrc + "; " +
"style-src 'self' 'unsafe-inline'; " +
imgSrc + "; " +
connectSrc + "; " +
"font-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'self'"
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Allow popups to maintain window.opener for OAuth flows
c.Response().Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups")
// Prevent being embedded in iframes from different origins
c.Response().Header().Set("X-Frame-Options", "SAMEORIGIN")
// Prevent MIME-type sniffing
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
// Force HTTPS in production (only if request is already HTTPS)
if c.Request().TLS != nil || c.Request().Header.Get("X-Forwarded-Proto") == "https" {
// max-age=31536000 (1 year), includeSubDomains for all subdomains
c.Response().Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
c.Response().Header().Set("Content-Security-Policy", csp)
return next(c)
}
}
}