init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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
}