Ctrl K
ComunidadGuías…

backend/server/echo_routes.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}