init push
This commit is contained in:
471
internal/app/api/middleware.go
Normal file
471
internal/app/api/middleware.go
Normal file
@@ -0,0 +1,471 @@
|
||||
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 == "<nil>" {
|
||||
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 != "<nil>" {
|
||||
args = append(args, slog.String("internal_detail", detail))
|
||||
}
|
||||
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "<nil>" {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user