2026-03-14 10:03:29 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
|
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
2026-04-01 16:25:45 +00:00
|
|
|
"fmt"
|
2026-03-14 10:03:29 +00:00
|
|
|
"net/http"
|
2026-04-01 16:25:45 +00:00
|
|
|
"runtime/debug"
|
2026-03-14 10:03:29 +00:00
|
|
|
"strings"
|
2026-04-01 13:48:53 +00:00
|
|
|
"time"
|
2026-03-14 10:03:29 +00:00
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-01 13:31:19 +00:00
|
|
|
// requestIDContextKey is the Gin context key used by requestIDMiddleware.
|
|
|
|
|
const requestIDContextKey = "request_id"
|
|
|
|
|
|
2026-04-01 13:48:53 +00:00
|
|
|
// requestStartContextKey stores when the request began so handlers can
|
|
|
|
|
// calculate elapsed duration for response metadata.
|
|
|
|
|
const requestStartContextKey = "request_start"
|
|
|
|
|
|
2026-04-01 16:25:45 +00:00
|
|
|
// recoveryMiddleware converts panics into a standard JSON error envelope.
|
|
|
|
|
// This keeps internal failures consistent with the rest of the framework
|
|
|
|
|
// and avoids Gin's default plain-text 500 response.
|
|
|
|
|
func recoveryMiddleware() gin.HandlerFunc {
|
|
|
|
|
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
|
|
|
|
fmt.Fprintf(gin.DefaultErrorWriter, "[Recovery] panic recovered: %v\n", recovered)
|
|
|
|
|
debug.PrintStack()
|
|
|
|
|
c.AbortWithStatusJSON(http.StatusInternalServerError, Fail(
|
|
|
|
|
"internal_server_error",
|
|
|
|
|
"Internal server error",
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:03:29 +00:00
|
|
|
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
|
|
|
|
|
// Requests to paths in the skip list are allowed through without authentication.
|
|
|
|
|
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
|
2026-04-02 02:06:45 +00:00
|
|
|
func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
2026-03-14 10:03:29 +00:00
|
|
|
return func(c *gin.Context) {
|
|
|
|
|
// Check whether the request path should bypass authentication.
|
2026-04-02 02:06:45 +00:00
|
|
|
for _, path := range skip() {
|
2026-04-01 16:56:09 +00:00
|
|
|
if isPublicPath(c.Request.URL.Path, path) {
|
2026-03-14 10:03:29 +00:00
|
|
|
c.Next()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header := c.GetHeader("Authorization")
|
|
|
|
|
if header == "" {
|
|
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "missing authorization header"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts := strings.SplitN(header, " ", 2)
|
|
|
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token {
|
|
|
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.Next()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 16:56:09 +00:00
|
|
|
// isPublicPath reports whether requestPath should bypass auth for publicPath.
|
|
|
|
|
// It matches the exact path and any nested subpath, but not sibling prefixes
|
|
|
|
|
// such as /swaggerx when the public path is /swagger.
|
|
|
|
|
func isPublicPath(requestPath, publicPath string) bool {
|
|
|
|
|
if publicPath == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
normalized := strings.TrimRight(publicPath, "/")
|
|
|
|
|
if normalized == "" {
|
|
|
|
|
normalized = "/"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if requestPath == normalized {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if normalized == "/" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return strings.HasPrefix(requestPath, normalized+"/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:03:29 +00:00
|
|
|
// requestIDMiddleware ensures every response carries an X-Request-ID header.
|
|
|
|
|
// If the client sends one, it is preserved; otherwise a random 16-byte hex
|
|
|
|
|
// string is generated. The ID is also stored in the Gin context as "request_id".
|
|
|
|
|
func requestIDMiddleware() gin.HandlerFunc {
|
|
|
|
|
return func(c *gin.Context) {
|
2026-04-01 13:48:53 +00:00
|
|
|
c.Set(requestStartContextKey, time.Now())
|
|
|
|
|
|
2026-03-14 10:03:29 +00:00
|
|
|
id := c.GetHeader("X-Request-ID")
|
|
|
|
|
if id == "" {
|
|
|
|
|
b := make([]byte, 16)
|
|
|
|
|
_, _ = rand.Read(b)
|
|
|
|
|
id = hex.EncodeToString(b)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:31:19 +00:00
|
|
|
c.Set(requestIDContextKey, id)
|
2026-03-14 10:03:29 +00:00
|
|
|
c.Header("X-Request-ID", id)
|
|
|
|
|
c.Next()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 13:31:19 +00:00
|
|
|
|
|
|
|
|
// GetRequestID returns the request ID assigned by requestIDMiddleware.
|
|
|
|
|
// Returns an empty string when the middleware was not applied.
|
2026-04-02 07:51:21 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
|
|
|
|
//
|
|
|
|
|
// id := api.GetRequestID(c)
|
2026-04-01 13:31:19 +00:00
|
|
|
func GetRequestID(c *gin.Context) string {
|
|
|
|
|
if v, ok := c.Get(requestIDContextKey); ok {
|
|
|
|
|
if s, ok := v.(string); ok {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-04-01 13:48:53 +00:00
|
|
|
|
|
|
|
|
// GetRequestDuration returns the elapsed time since requestIDMiddleware started
|
|
|
|
|
// handling the request. Returns 0 when the middleware was not applied.
|
2026-04-02 07:51:21 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
|
|
|
|
//
|
|
|
|
|
// d := api.GetRequestDuration(c)
|
2026-04-01 13:48:53 +00:00
|
|
|
func GetRequestDuration(c *gin.Context) time.Duration {
|
|
|
|
|
if v, ok := c.Get(requestStartContextKey); ok {
|
|
|
|
|
if started, ok := v.(time.Time); ok && !started.IsZero() {
|
|
|
|
|
return time.Since(started)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetRequestMeta returns request metadata collected by requestIDMiddleware.
|
|
|
|
|
// The returned meta includes the request ID and elapsed duration when
|
|
|
|
|
// available. It returns nil when neither value is available.
|
2026-04-02 07:51:21 +00:00
|
|
|
//
|
|
|
|
|
// Example:
|
|
|
|
|
//
|
|
|
|
|
// meta := api.GetRequestMeta(c)
|
2026-04-01 13:48:53 +00:00
|
|
|
func GetRequestMeta(c *gin.Context) *Meta {
|
|
|
|
|
meta := &Meta{}
|
|
|
|
|
|
|
|
|
|
if id := GetRequestID(c); id != "" {
|
|
|
|
|
meta.RequestID = id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if duration := GetRequestDuration(c); duration > 0 {
|
|
|
|
|
meta.Duration = duration.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if meta.RequestID == "" && meta.Duration == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return meta
|
|
|
|
|
}
|