The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).
- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
/openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
/openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
/sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
{"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"music-server/internal/db/repository"
|
|
"music-server/internal/logging"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type tokenKey struct{}
|
|
|
|
// Token returns the session token of an authenticated request.
|
|
func Token(ctx context.Context) string {
|
|
token, _ := ctx.Value(tokenKey{}).(string)
|
|
return token
|
|
}
|
|
|
|
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
|
func BearerToken(header string) (string, bool) {
|
|
token, ok := strings.CutPrefix(header, "Bearer ")
|
|
return token, ok && token != "" && !strings.Contains(token, " ")
|
|
}
|
|
|
|
// TokenAuth validates the session token for operations that declare a security requirement.
|
|
// When disabled is true all requests are let through.
|
|
func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
|
|
return func(ctx huma.Context, next func(huma.Context)) {
|
|
if disabled || len(ctx.Operation().Security) == 0 {
|
|
next(ctx)
|
|
return
|
|
}
|
|
|
|
header := ctx.Header("Authorization")
|
|
if header == "" {
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
|
|
return
|
|
}
|
|
token, ok := BearerToken(header)
|
|
if !ok {
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
|
|
return
|
|
}
|
|
|
|
session, err := q.GetSession(ctx.Context(), token)
|
|
if err != nil {
|
|
logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
|
|
return
|
|
}
|
|
|
|
if time.Now().After(session.ExpiresAt.Time) {
|
|
// Clean up in the background with a context that outlives the request.
|
|
go func(ctx context.Context) {
|
|
if err := q.DeleteSession(ctx, token); err != nil {
|
|
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
|
|
}
|
|
}(context.WithoutCancel(ctx.Context()))
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
|
|
return
|
|
}
|
|
|
|
next(huma.WithValue(ctx, tokenKey{}, token))
|
|
}
|
|
}
|
|
|
|
type clientKey struct{}
|
|
|
|
// Client holds request details that handlers need but that are not part of the API input.
|
|
type Client struct {
|
|
IP string
|
|
UserAgent string
|
|
}
|
|
|
|
// ClientInfo stores the client's IP and user agent in the request context.
|
|
func ClientInfo(ctx huma.Context, next func(huma.Context)) {
|
|
next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")}))
|
|
}
|
|
|
|
// ClientFrom returns the client details stored by ClientInfo.
|
|
func ClientFrom(ctx context.Context) Client {
|
|
client, _ := ctx.Value(clientKey{}).(Client)
|
|
return client
|
|
}
|