package api import ( "context" "errors" "fmt" "log/slog" "runtime/debug" "sort" "strconv" "strings" "sync" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/helmet" "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/gofiber/fiber/v2/middleware/timeout" "github.com/gofiber/fiber/v2/utils" "github.com/prometheus/client_golang/prometheus" "wucher/internal/config" "wucher/internal/shared/pkg/apperrorsx" ) var ( httpMetricsRegisterOnce = sync.Once{} httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total number of HTTP requests.", }, []string{"method", "path", "status"}) httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration in seconds.", Buckets: prometheus.DefBuckets, }, []string{"method", "path", "status"}) ) // UseDefaultMiddlewares applies baseline best-practice middleware. func UseDefaultMiddlewares(app *fiber.App, cfg config.HTTPConfig) { registerHTTPMetrics() app.Use(recover.New(recover.Config{ EnableStackTrace: true, StackTraceHandler: func(c *fiber.Ctx, err any) { slog.Error("panic recovered", "method", c.Method(), "path", c.Path(), "error", err, "stack", string(debug.Stack())) }, })) app.Use(requestid.New(requestid.Config{ Header: fiber.HeaderXRequestID, ContextKey: "requestid", Generator: utils.UUIDv4, })) app.Use(func(c *fiber.Ctx) error { // Use a request-scoped context that keeps request values but is not canceled // as soon as fasthttp starts draining on server shutdown. c.SetUserContext(context.WithoutCancel(c.Context())) return c.Next() }) app.Use(httpAccessLogMiddleware()) app.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, Next: func(c *fiber.Ctx) bool { return isEventStreamRequest(c) || isWOPIRequest(c) }, })) app.Use(requestTimeoutMiddleware(cfg)) app.Use(httpMetricsMiddleware()) app.Use(helmet.New(helmet.Config{ XFrameOptions: "DENY", ReferrerPolicy: "no-referrer", PermissionPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=()", HSTSMaxAge: cfg.HSTSMaxAge, HSTSPreloadEnabled: cfg.HSTSMaxAge > 0, })) app.Use(disallowUnsafeMethods()) corsAllowCredentials := cfg.CORSAllowCredentials && !containsWildcardOrigin(cfg.CORSAllowOrigins) app.Use(cors.New(cors.Config{ AllowOrigins: cfg.CORSAllowOrigins, AllowMethods: cfg.CORSAllowMethods, AllowHeaders: cfg.CORSAllowHeaders, ExposeHeaders: cfg.CORSExposeHeaders, AllowCredentials: corsAllowCredentials, MaxAge: cfg.CORSMaxAge, })) app.Use(limiter.New(limiter.Config{ Max: cfg.RateLimitMax, Expiration: cfg.RateLimitWindow, Next: func(c *fiber.Ctx) bool { path := c.Path() return c.Method() == fiber.MethodOptions || path == "/health" || strings.HasPrefix(path, "/swagger") }, LimitReached: func(c *fiber.Ctx) error { retryAfter := int(cfg.RateLimitWindow.Seconds()) if retryAfter < 1 { retryAfter = 1 } c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter)) return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ "errors": []fiber.Map{{ "status": "429", "title": "Too Many Requests", "detail": "rate limit exceeded", }}, }) }, })) // Per-endpoint limiter (exact method+path match), configured from env/config. // Applied in addition to the global limiter above. endpointKeys := make([]string, 0, len(cfg.RateLimitByEndpoint)) for key := range cfg.RateLimitByEndpoint { endpointKeys = append(endpointKeys, key) } sort.Strings(endpointKeys) for _, endpointKey := range endpointKeys { rule := cfg.RateLimitByEndpoint[endpointKey] currentEndpointKey := endpointKey currentRule := rule app.Use(limiter.New(limiter.Config{ Max: currentRule.Max, Expiration: currentRule.Window, Next: func(c *fiber.Ctx) bool { return methodPathKey(c.Method(), c.Path()) != currentEndpointKey }, LimitReached: func(c *fiber.Ctx) error { retryAfter := int(currentRule.Window.Seconds()) if retryAfter < 1 { retryAfter = 1 } c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter)) return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ "errors": []fiber.Map{{ "status": "429", "title": "Too Many Requests", "detail": fmt.Sprintf("rate limit exceeded for %s", currentEndpointKey), }}, }) }, })) } } func httpAccessLogMiddleware() fiber.Handler { requestLogger := slog.Default() return func(c *fiber.Ctx) error { started := time.Now() err := c.Next() if c.Path() == "/health" { return err } statusCode := c.Response().StatusCode() level := slog.LevelInfo if statusCode >= fiber.StatusInternalServerError || err != nil { level = slog.LevelError } else if statusCode >= fiber.StatusBadRequest { level = slog.LevelWarn } requestID := strings.TrimSpace(fmt.Sprint(c.Locals("requestid"))) if requestID == "" || requestID == "" { requestID = strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID)) } args := []any{ slog.String("request_id", requestID), slog.String("method", c.Method()), slog.String("path", c.Path()), slog.Int("status", statusCode), slog.Duration("latency", time.Since(started)), slog.String("ip", c.IP()), } if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail))); detail != "" && detail != "" { args = append(args, slog.String("internal_detail", detail)) } if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "" { args = append(args, slog.String("detail", detail)) } if err != nil { args = append(args, slog.Any("error", err)) args = append(args, slog.Any("error_chain", buildErrorChain(err))) } if statusCode >= fiber.StatusInternalServerError { if body := strings.TrimSpace(string(c.Response().Body())); body != "" { args = append(args, slog.String("response_body", truncateForLog(body, 1800))) } } requestLogger.Log(c.UserContext(), level, "http request", args...) return err } } func buildErrorChain(err error) []string { if err == nil { return nil } chain := make([]string, 0, 8) seen := map[string]struct{}{} for current := err; current != nil; current = errors.Unwrap(current) { msg := current.Error() if msg == "" { msg = fmt.Sprintf("%T", current) } key := fmt.Sprintf("%T:%s", current, msg) if _, ok := seen[key]; ok { break } seen[key] = struct{}{} chain = append(chain, fmt.Sprintf("%T: %s", current, msg)) } return chain } func truncateForLog(v string, max int) string { if max <= 0 || len(v) <= max { return v } if max <= 3 { return v[:max] } return v[:max-3] + "..." } func httpMetricsMiddleware() fiber.Handler { return func(c *fiber.Ctx) error { started := time.Now() method := normalizeHTTPMethod(c.Method()) err := c.Next() path := metricsPathLabel(c) if shouldSkipHTTPMetrics(path) { return err } status := strconv.Itoa(c.Response().StatusCode()) httpRequestsTotal.WithLabelValues(method, path, status).Inc() httpRequestDurationSeconds.WithLabelValues(method, path, status).Observe(time.Since(started).Seconds()) return err } } func registerHTTPMetrics() { httpMetricsRegisterOnce.Do(func() { registerHTTPCollector(httpRequestsTotal) registerHTTPCollector(httpRequestDurationSeconds) }) } func registerHTTPCollector(collector prometheus.Collector) { if err := prometheus.Register(collector); err != nil { var alreadyRegistered prometheus.AlreadyRegisteredError if errors.As(err, &alreadyRegistered) { return } panic(err) } } func shouldSkipHTTPMetrics(path string) bool { return path == "/metrics" || path == "/health" } func metricsPathLabel(c *fiber.Ctx) string { route := c.Route() if route != nil { if routePath := strings.TrimSpace(utils.CopyString(route.Path)); routePath != "" { return routePath } } return "unmatched" } func normalizeHTTPMethod(method string) string { method = strings.ToUpper(strings.TrimSpace(utils.CopyString(method))) if method == "" { return "UNKNOWN" } switch method { case fiber.MethodGet, fiber.MethodHead, fiber.MethodPost, fiber.MethodPut, fiber.MethodPatch, fiber.MethodDelete, fiber.MethodOptions, fiber.MethodTrace, fiber.MethodConnect: return method default: return "OTHER" } } func requestTimeoutMiddleware(cfg config.HTTPConfig) fiber.Handler { defaultTimeout := cfg.RequestTimeout overrides := cfg.RequestTimeoutByPath if defaultTimeout <= 0 && len(overrides) == 0 { return func(c *fiber.Ctx) error { return c.Next() } } return func(c *fiber.Ctx) error { if isEventStreamRequest(c) { return c.Next() } requestTimeout := defaultTimeout if override, ok := timeoutForRequest(overrides, c.Method(), c.Path(), c.Route()); ok { requestTimeout = override } if requestTimeout <= 0 { return c.Next() } return timeout.NewWithContext(func(c *fiber.Ctx) error { return c.Next() }, requestTimeout)(c) } } func isEventStreamRequest(c *fiber.Ctx) bool { if c == nil { return false } path := strings.TrimSpace(c.Path()) if strings.HasSuffix(path, "/file-manager/events") { return true } accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept))) return strings.Contains(accept, "text/event-stream") } // isWOPIRequest reports whether the request targets the Collabora/WOPI // endpoints. WOPI file-content responses carry raw office bytes (an .xlsx is a // ZIP container that is already compressed); gzipping them makes Collabora // store the compressed stream verbatim as the document, producing a file that // is consistently corrupt. Office/JSON WOPI payloads gain nothing from gzip, so // the whole group is excluded from the compress middleware. func isWOPIRequest(c *fiber.Ctx) bool { if c == nil { return false } return strings.HasPrefix(strings.TrimSpace(c.Path()), "/wopi/") } func timeoutForRequest(overrides map[string]time.Duration, method, path string, route *fiber.Route) (time.Duration, bool) { if len(overrides) == 0 { return 0, false } normalizedMethod := strings.ToUpper(strings.TrimSpace(method)) trimmedPath := strings.TrimSpace(path) if route != nil { registeredPath := strings.TrimSpace(route.Path) if registeredPath != "" && registeredPath != "/" { key := timeoutOverrideKey(normalizedMethod, registeredPath) if v, ok := overrides[key]; ok { return v, true } } } if v, ok := overrides[timeoutOverrideKey(normalizedMethod, trimmedPath)]; ok { return v, true } for key, duration := range overrides { parts := strings.SplitN(key, " ", 2) if len(parts) != 2 { continue } if strings.ToUpper(strings.TrimSpace(parts[0])) != normalizedMethod { continue } if routePatternMatches(strings.TrimSpace(parts[1]), trimmedPath) { return duration, true } } return 0, false } func methodPathKey(method, path string) string { return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path) } func timeoutOverrideKey(method, path string) string { return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path) } func routePatternMatches(pattern, path string) bool { if pattern == "" { return false } if pattern == path { return true } pSeg := splitPathSegments(pattern) rSeg := splitPathSegments(path) if len(pSeg) == 0 && len(rSeg) == 0 { return true } i := 0 for ; i < len(pSeg); i++ { segment := pSeg[i] if segment == "*" || strings.HasPrefix(segment, "*") { return true } if i >= len(rSeg) { return false } if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "+") { continue } if segment != rSeg[i] { return false } } return i == len(rSeg) } func splitPathSegments(path string) []string { trimmed := strings.TrimSpace(path) trimmed = strings.TrimPrefix(trimmed, "/") trimmed = strings.TrimSuffix(trimmed, "/") if trimmed == "" { return nil } return strings.Split(trimmed, "/") } func disallowUnsafeMethods() fiber.Handler { return func(c *fiber.Ctx) error { if c.Method() == fiber.MethodTrace || c.Method() == "TRACK" { return c.SendStatus(fiber.StatusMethodNotAllowed) } return c.Next() } } func containsWildcardOrigin(origins string) bool { for _, origin := range strings.Split(origins, ",") { if strings.TrimSpace(origin) == "*" { return true } } return false }