middleware

package
v1.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: AGPL-3.0 Imports: 19 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// RequestIDKey is the context key for request ID
	RequestIDKey = "request_id"
	// RequestIDHeader is the header key for request ID
	RequestIDHeader = "X-Request-ID"
	// UserIDKey is the context key AuthMiddleware uses to store the
	// authenticated user's ID. Declared here so the access log can attribute
	// requests without importing the auth package.
	UserIDKey = "userID"
)
View Source
const HeaderIdempotencyKey = "Idempotency-Key"

HeaderIdempotencyKey is the request header a client sets to make a mutating request safe to retry. Spelled as in the IETF "Idempotency-Key Header Field" draft, which is what HTTP clients and API tooling already expect.

View Source
const HeaderIdempotencyReplayed = "Idempotency-Replayed"

HeaderIdempotencyReplayed is set on a response served from a stored record rather than by executing the request. Clients use it to tell "my write was applied just now" from "my write had already been applied".

Variables

View Source
var (
	// AppInfo is a constant gauge (value 1) with version and go_version labels.
	AppInfo = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "app_info",
			Help: "Application build information.",
		},
		[]string{"version", "go_version"},
	)

	// AppUptimeSeconds reports seconds since server start.
	AppUptimeSeconds = prometheus.NewGaugeFunc(
		prometheus.GaugeOpts{
			Name: "app_uptime_seconds",
			Help: "Seconds since server start.",
		},
		func() float64 { return 0 },
	)

	// HealthCheckStatus is 1 when the service is healthy, 0 when unhealthy.
	HealthCheckStatus = prometheus.NewGauge(
		prometheus.GaugeOpts{
			Name: "health_check_status",
			Help: "Health check status: 1 = healthy, 0 = unhealthy.",
		},
	)
)
View Source
var (
	// HTTPRequestsTotal counts total HTTP requests by method, path, and status.
	HTTPRequestsTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total number of HTTP requests.",
		},
		[]string{"method", "path", "status"},
	)

	// HTTPRequestDurationSeconds tracks HTTP request latency.
	HTTPRequestDurationSeconds = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "HTTP request latency in seconds.",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"method", "path"},
	)

	// HTTPResponseSizeBytes tracks HTTP response sizes.
	HTTPResponseSizeBytes = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_response_size_bytes",
			Help:    "HTTP response size in bytes.",
			Buckets: prometheus.ExponentialBuckets(100, 10, 7),
		},
		[]string{"method", "path"},
	)

	// HTTPRequestsInFlight tracks the number of in-flight HTTP requests.
	HTTPRequestsInFlight = prometheus.NewGauge(
		prometheus.GaugeOpts{
			Name: "http_requests_in_flight",
			Help: "Number of HTTP requests currently being processed.",
		},
	)

	// APIPanicsRecoveredTotal counts panics recovered by the recovery middleware.
	APIPanicsRecoveredTotal = prometheus.NewCounter(
		prometheus.CounterOpts{
			Name: "api_panics_recovered_total",
			Help: "Total number of panics recovered.",
		},
	)
)
View Source
var (
	// RateLimitHitsTotal counts requests that were rejected by a rate limiter.
	//
	// The "limiter" label names the bucket that did the rejecting (see the
	// names passed in cmd/api/main.go). Without it every limiter reports into
	// one undifferentiated series, and a 429 on a route covered by two
	// limiters — e.g. /flights, which carries both the coarse "general"
	// limiter and the "search" limiter — cannot be attributed to either.
	RateLimitHitsTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "rate_limit_hits_total",
			Help: "Total number of requests rejected by a rate limiter, by limiter and route.",
		},
		[]string{"limiter", "path"},
	)

	// RateLimitRequestsTotal counts every request that passed *through* a rate
	// limiter, whether it was allowed or rejected. It is the denominator for
	// RateLimitHitsTotal: on its own a rejection rate says nothing about
	// whether a limit is correctly sized, because 2 rejections/s is a
	// non-event against 200 req/s of traffic and an outage against 3 req/s.
	RateLimitRequestsTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "rate_limit_requests_total",
			Help: "Total number of requests evaluated by a rate limiter (allowed and rejected), by limiter and route.",
		},
		[]string{"limiter", "path"},
	)
)
View Source
var IdempotencyRequestsTotal = prometheus.NewCounterVec(
	prometheus.CounterOpts{
		Name: "idempotency_requests_total",
		Help: "Requests carrying an Idempotency-Key header, by outcome.",
	},
	[]string{"outcome"},
)

IdempotencyRequestsTotal counts requests that carried an Idempotency-Key, by outcome: executed (claimed and run), replayed, in_progress, mismatch, not_replayable, invalid_key, unavailable.

Functions

func AdminMiddleware

func AdminMiddleware(jwtManager *jwt.Manager, adminEmail string, getUserEmail func(userID uuid.UUID) (string, error)) gin.HandlerFunc

AdminMiddleware creates a middleware that restricts access to admin users only. It requires the auth middleware to have already set "userID" in the context. The admin user is determined by matching the user's email against the ADMIN_EMAIL env var.

func AuthMiddleware

func AuthMiddleware(jwtManager *jwt.Manager, publicPaths []string) gin.HandlerFunc

AuthMiddleware enforces JWT authentication on all routes except explicitly allowed public paths. It extracts the user ID from the token and sets it in the Gin context as "userID".

func IdempotencyMiddleware added in v1.3.2

func IdempotencyMiddleware(store IdempotencyStore) gin.HandlerFunc

IdempotencyMiddleware makes mutating requests safe to retry when the client opts in with an `Idempotency-Key` header.

The header is the entire opt-in. A request without it takes exactly the path it took before this middleware existed — which is what keeps the current frontend, and every other existing client, unaffected — while a client that queues writes offline can replay its queue without risking a duplicate logbook entry.

Shape of the guarantee:

  • Scoped per authenticated user. Unauthenticated endpoints (login, registration, password reset) are not covered: there is no user to key the record by, and re-running them is not a logbook-integrity problem.
  • The first request with a key executes; concurrent duplicates get 409; later duplicates get the stored response verbatim, plus `Idempotency-Replayed: true`.
  • Reusing one key for a different request body gets 422 rather than the first request's answer.
  • Server errors (5xx) and panics release the key, because a 5xx says nothing about whether the write landed and the client must stay free to retry.

It must be registered after AuthMiddleware (it reads "userID" from the context) and after the rate limiters, so a rejected request never consumes a key.

func LoggerMiddleware

func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc

LoggerMiddleware creates a middleware that emits a structured access-log line for every request: who (user ID, client IP), what (method, path), when (timestamp via the logger), and the outcome (status, latency). It also assigns each request a unique ID, exposed both in the Gin context and the X-Request-ID response header for correlation.

The line is written through the provided *slog.Logger; pass nil to use slog.Default(). The log level scales with the response status: 5xx logs at Error, 4xx at Warn, everything else at Info.

func MaxBodyBytesMiddleware added in v1.3.0

func MaxBodyBytesMiddleware(defaultLimit, multipartLimit int64, overrides map[string]int64) gin.HandlerFunc

MaxBodyBytesMiddleware caps the request body size for non-multipart requests. ShouldBindJSON (and json.NewDecoder) read the body in full before any application-level validation runs, so without a cap a large JSON payload is buffered entirely in memory regardless of what the handler goes on to check.

Multipart (file upload) requests get their own, larger cap rather than being exempted. router.MaxMultipartMemory is NOT a total-size limit — it only sets how much of a part is buffered in RAM before Go spills the remainder to temp files on disk, with no ceiling on the remainder. The CSV upload handler's own "max 10 MB" check runs only AFTER c.Request.FormFile has consumed the whole body, so without a cap here the server pays the full cost of receiving and storing whatever was sent before rejecting it (measured: a 500 MB upload consumed 486 MB of disk and still returned "File too large").

overrides maps a path suffix (matched the same way as RateLimitByPath/RateLimitByPathPrefix) to a larger limit, for the rare JSON endpoint that legitimately carries a bigger payload (e.g. restoring a full logbook backup via POST /imports/json). The first matching suffix wins; unmatched paths get defaultLimit.

func MetricsMiddleware

func MetricsMiddleware() gin.HandlerFunc

MetricsMiddleware returns a Gin middleware that records Prometheus metrics for every HTTP request: counter, duration histogram, response size histogram, and in-flight gauge.

func NewRateLimitMiddleware

func NewRateLimitMiddleware(name string, rate int64, period time.Duration) gin.HandlerFunc

NewRateLimitMiddleware creates a Gin middleware that rate-limits requests. rate is the number of requests allowed per period (e.g., 10 requests per 1 minute). It uses Gin's c.ClientIP() to key rate limits by the real client IP (respecting X-Real-IP / X-Forwarded-For headers set by nginx) instead of the proxy's address.

func NewUserRateLimitMiddleware added in v1.3.0

func NewUserRateLimitMiddleware(name string, rate int64, period time.Duration) gin.HandlerFunc

NewUserRateLimitMiddleware is like NewRateLimitMiddleware, but keys by the authenticated user's ID (set by AuthMiddleware as "userID") when present, falling back to client IP otherwise (e.g. the request never reached AuthMiddleware's authenticated branch). Per-user keying is more precise for logged-in traffic than per-IP: it isn't inflated by users sharing a NAT/office IP, and isn't defeated by one user rotating source IPs.

func RateLimitByPath

func RateLimitByPath(rl gin.HandlerFunc, paths ...string) gin.HandlerFunc

RateLimitByPath applies a rate-limit middleware only to requests whose path (relative to the router group) matches one of the given suffixes.

func RateLimitByPathPrefix added in v1.2.9

func RateLimitByPathPrefix(rl gin.HandlerFunc, prefixes ...string) gin.HandlerFunc

RateLimitByPathPrefix applies a rate-limit middleware only to requests whose path starts with one of the given prefixes. Unlike RateLimitByPath's suffix matching, this is needed for routes ending in an opaque token (e.g. "/sign/{token}"), which never share a fixed suffix.

func RateLimitByPathSegment added in v1.3.2

func RateLimitByPathSegment(rl gin.HandlerFunc, segments ...string) gin.HandlerFunc

RateLimitByPathSegment applies a rate-limit middleware to every request whose path contains one of the given segments, wherever it appears.

Neither suffix nor prefix matching covers a sub-collection that sits in the middle of a path and also has per-item routes under it: "/licenses/{id}/files" has no fixed prefix and "/licenses/{id}/files/{fileId}" has no fixed suffix, so matching on the "images" segment is the only way to cover the collection and its items with one predicate.

func RateLimitByPathSegmentForMethods added in v1.3.2

func RateLimitByPathSegmentForMethods(rl gin.HandlerFunc, methods []string, segments ...string) gin.HandlerFunc

RateLimitByPathSegmentForMethods is RateLimitByPathSegment narrowed to a set of HTTP methods, so one sub-collection can carry different budgets for reads and writes.

A single budget across both is wrong whenever a resource is written rarely but read repeatedly: uploading an image is heavy and deliberate, while listing and displaying images happens once per card on a list page and again on every revisit. Sharing one tight bucket makes the read path fail long before the write path is anywhere near abusive.

func RateLimitByPathWithQueryParam added in v1.3.0

func RateLimitByPathWithQueryParam(rl gin.HandlerFunc, path, queryParam string) gin.HandlerFunc

RateLimitByPathWithQueryParam applies a rate-limit middleware only to requests whose path (relative to the router group) ends with the given suffix AND which carry a non-empty queryParam. This targets expensive query variants of an otherwise-cheap route (e.g. GET /flights only becomes costly once a free-text search "q" is present) without limiting plain, cheap requests to the same path.

func RecoveryWithMetrics

func RecoveryWithMetrics() gin.HandlerFunc

RecoveryWithMetrics returns a Gin middleware that recovers from panics, increments the api_panics_recovered_total counter, and returns a 500 response.

func RegisterAppMetrics

func RegisterAppMetrics(version string, startedAt time.Time)

RegisterAppMetrics registers app_info and app_uptime_seconds gauges. version is the application version string (e.g. "1.0.0" or a git SHA).

func RequestTimeoutMiddleware added in v1.3.0

func RequestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc

RequestTimeoutMiddleware bounds every request's context to the given duration. Handlers pass c.Request.Context() down to database calls (QueryContext/ExecContext), so without this a slow or unbounded query has no deadline and holds its pool connection until it finishes on its own. This is defense in depth alongside the database's own statement_timeout: whichever fires first cancels the query and frees the connection.

func SecurityHeadersMiddleware

func SecurityHeadersMiddleware() gin.HandlerFunc

SecurityHeadersMiddleware adds standard security headers to all API responses.

Types

type DBStatsCollector

type DBStatsCollector struct {
	// contains filtered or unexported fields
}

DBStatsCollector implements prometheus.Collector and exposes sql.DBStats as Prometheus gauges on every scrape.

func NewDBStatsCollector

func NewDBStatsCollector(db *sql.DB) *DBStatsCollector

NewDBStatsCollector creates a collector that reads stats from db on every scrape.

func (*DBStatsCollector) Collect

func (c *DBStatsCollector) Collect(ch chan<- prometheus.Metric)

Collect reads the current sql.DBStats and sends each metric value.

func (*DBStatsCollector) Describe

func (c *DBStatsCollector) Describe(ch chan<- *prometheus.Desc)

Describe sends the descriptors of each metric to the channel.

type IdempotencyStore added in v1.3.2

type IdempotencyStore interface {
	Begin(ctx context.Context, userID uuid.UUID, key string, requestHash []byte) (service.IdempotencyClaim, error)
	Finish(ctx context.Context, userID uuid.UUID, key string, claimedAt time.Time, resp service.IdempotentResponse) error
	Abandon(ctx context.Context, userID uuid.UUID, key string, claimedAt time.Time) error
	MaxResponseBytes() int
}

IdempotencyStore is the slice of the idempotency service this middleware needs. Declared here so the middleware can be unit-tested without a database.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL