472 lines
12 KiB
Go
472 lines
12 KiB
Go
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
|
|
}
|