feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
@@ -0,0 +1,131 @@
// Package basicauth gates a route group behind HTTP Basic authentication.
//
// The middleware delegates the credential check to a Verifier.
// Production wires `*admin.Service` (Postgres-backed, bcrypt cost 12).
// The bundled StaticVerifier is a test utility — it accepts any
// non-empty username together with a fixed password so the contract
// test can exercise the admin route group without booting a database.
// Production wiring never references StaticVerifier.
package basicauth
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
)
// DefaultRealm is the realm advertised on `WWW-Authenticate` for the admin
// surface.
const DefaultRealm = "galaxy-admin"
// usernameContextKey is the unexported context key used to expose the
// authenticated admin username to downstream handlers (e.g. for
// soft-delete audit trails). The unexported value type prevents
// accidental collisions with keys defined in unrelated packages.
type usernameContextKey struct{}
// Verifier validates a username/password pair. Implementations must run in
// constant time relative to the credential bytes.
type Verifier interface {
// Verify reports whether the supplied credentials are accepted. A non-nil
// error indicates an unexpected verifier failure, distinct from a clean
// rejection (false, nil).
Verify(ctx context.Context, username, password string) (bool, error)
}
// UsernameFromContext returns the authenticated admin username stored on
// ctx by Middleware. The boolean reports whether a value was found.
func UsernameFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(usernameContextKey{}).(string)
if !ok {
return "", false
}
return value, true
}
// WithUsername stores username on ctx under the package-private context
// key. Exposed for tests that need to build a context outside the
// middleware.
func WithUsername(ctx context.Context, username string) context.Context {
return context.WithValue(ctx, usernameContextKey{}, username)
}
// Middleware returns a gin middleware that enforces Basic authentication via
// verifier. realm is advertised on `WWW-Authenticate`. A nil verifier behaves
// as a deny-all verifier, suitable for the operating mode where the admin
// surface must remain mounted but inaccessible.
func Middleware(verifier Verifier, realm string) gin.HandlerFunc {
if realm == "" {
realm = DefaultRealm
}
challenge := `Basic realm="` + realm + `"`
return func(c *gin.Context) {
username, password, ok := c.Request.BasicAuth()
if !ok {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "basic authentication is required")
return
}
if verifier == nil {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
return
}
accepted, err := verifier.Verify(c.Request.Context(), username, password)
if err != nil {
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "credential verification failed")
return
}
if !accepted {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
return
}
c.Request = c.Request.WithContext(WithUsername(c.Request.Context(), username))
c.Next()
}
}
// StaticVerifier accepts any non-empty username together with a
// fixed shared password. It is a test-only utility: the OpenAPI
// contract test wires it to exercise the admin route group without
// booting a database. Production wiring uses the Postgres-backed
// `*backend/internal/admin.Service`.
type StaticVerifier struct {
// Password is the shared secret. An empty value disables the verifier
// (every request is rejected).
Password string
}
// NewStaticVerifier returns a StaticVerifier with the supplied password.
func NewStaticVerifier(password string) StaticVerifier {
return StaticVerifier{Password: password}
}
// Verify accepts any non-empty username together with the configured password.
// The password comparison runs in constant time. An empty configured password
// rejects every request.
func (v StaticVerifier) Verify(_ context.Context, username, password string) (bool, error) {
if strings.TrimSpace(username) == "" {
return false, nil
}
if v.Password == "" {
return false, nil
}
if subtle.ConstantTimeCompare([]byte(password), []byte(v.Password)) != 1 {
return false, nil
}
return true, nil
}
@@ -0,0 +1,58 @@
// Package geocounter exposes the gin middleware that records
// `(user_id, country)` counters for every authenticated user-surface
// request. The middleware sits one layer below `userid.Middleware` in
// the route chain: it relies on the parsed user id already being on
// the request context.
//
// The middleware never blocks: the underlying counter implementation
// looks up the country synchronously (mmap read) and dispatches the
// database upsert to a fire-and-forget goroutine. Errors from the
// asynchronous path are logged inside the geo service, never surfaced
// to the response.
package geocounter
import (
"context"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Service is the narrow contract the middleware needs from the geo
// package. It is satisfied by `*geo.Service` directly; tests inject a
// recording stub. A nil Service is allowed and disables the
// middleware's side effect.
type Service interface {
IncrementCounterAsync(ctx context.Context, userID uuid.UUID, sourceIP string)
}
// Middleware returns a gin handler that, after the wrapped handler
// chain has run, dispatches an `IncrementCounterAsync` call for the
// authenticated user and the originating IP. svc may be nil, in which
// case the middleware is a no-op pass-through.
//
// The middleware reads the user id from the request context populated
// by `userid.Middleware`; routes that mount this middleware without
// `userid.Middleware` ahead of it will silently skip the increment
// because the user id is absent.
func Middleware(svc Service) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if svc == nil {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok || userID == uuid.Nil {
return
}
ip := clientip.ExtractSourceIP(c)
if ip == "" {
return
}
svc.IncrementCounterAsync(c.Request.Context(), userID, ip)
}
}
@@ -0,0 +1,164 @@
package geocounter_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"galaxy/backend/internal/server/middleware/geocounter"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type recordingSvc struct {
mu sync.Mutex
calls []recordedCall
}
type recordedCall struct {
UserID uuid.UUID
SourceIP string
}
func (r *recordingSvc) IncrementCounterAsync(_ context.Context, userID uuid.UUID, sourceIP string) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{UserID: userID, SourceIP: sourceIP})
}
func (r *recordingSvc) snapshot() []recordedCall {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedCall, len(r.calls))
copy(out, r.calls)
return out
}
func newEngine(t *testing.T, svc geocounter.Service) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(userid.Middleware())
r.Use(geocounter.Middleware(svc))
r.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
func TestMiddlewareInvokesIncrementOnAuthenticatedRequest(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.Header.Set("X-Forwarded-For", "203.0.113.5")
req.RemoteAddr = "10.0.0.1:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
calls := svc.snapshot()
if len(calls) != 1 {
t.Fatalf("calls: want 1, got %+v", calls)
}
if calls[0].UserID != userID {
t.Errorf("user id: want %s, got %s", userID, calls[0].UserID)
}
if calls[0].SourceIP != "203.0.113.5" {
t.Errorf("source ip: want 203.0.113.5, got %q", calls[0].SourceIP)
}
}
func TestMiddlewareFallsBackToRemoteAddr(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = "198.51.100.7:60000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
calls := svc.snapshot()
if len(calls) != 1 {
t.Fatalf("calls: want 1, got %+v", calls)
}
if calls[0].SourceIP != "198.51.100.7" {
t.Errorf("source ip: want 198.51.100.7, got %q", calls[0].SourceIP)
}
}
func TestMiddlewareSkipsWhenNoSourceIP(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = ""
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if calls := svc.snapshot(); len(calls) != 0 {
t.Fatalf("calls: want 0, got %+v", calls)
}
}
func TestMiddlewareSkipsWithoutUserContext(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
gin.SetMode(gin.TestMode)
r := gin.New()
// No userid.Middleware on this chain.
r.Use(geocounter.Middleware(svc))
r.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.RemoteAddr = "203.0.113.5:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if calls := svc.snapshot(); len(calls) != 0 {
t.Fatalf("calls: want 0, got %+v", calls)
}
}
func TestMiddlewareNilServiceIsPassThrough(t *testing.T) {
t.Parallel()
r := newEngine(t, nil)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = "203.0.113.5:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status with nil service: want 200, got %d", rec.Code)
}
}
@@ -0,0 +1,44 @@
// Package logging emits a single info-level access log entry per HTTP request,
// enriched with the active OpenTelemetry trace fields and the resolved request
// id when present.
package logging
import (
"time"
"galaxy/backend/internal/server/middleware/requestid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Middleware returns the access-log gin middleware. The provided logger should
// already carry the per-process service-name field; the middleware adds the
// request method, matched route, status, latency, request id, and trace
// fields.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
fields := make([]zap.Field, 0, 6)
fields = append(fields,
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.Duration("duration", duration),
)
if requestID, ok := requestid.FromGin(c); ok {
fields = append(fields, zap.String("request_id", requestID))
}
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
logger.Info("http request", fields...)
}
}
@@ -0,0 +1,110 @@
// Package metrics emits per-request OpenTelemetry counters and histograms
// scoped by route group.
//
// The metric names are fixed by `backend/README.md` §15:
//
// - http_requests_total{group, method, route, status}
// - http_request_duration_seconds{group, method, route, status}
//
// One Middleware instance per route group keeps the `group` attribute stable
// across requests while allowing the gin router to share the same Meter.
package metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Group identifies the route family that emits the metric. The set is closed
// and matches the prefixes registered by router.New.
type Group string
const (
// GroupRoot covers `/healthz`, `/readyz`, and unmatched routes.
GroupRoot Group = "root"
// GroupProbes covers the readiness/liveness probes when reported separately
// from other root-level traffic.
GroupProbes Group = "probes"
// GroupPublic covers `/api/v1/public/*` endpoints.
GroupPublic Group = "public"
// GroupUser covers `/api/v1/user/*` endpoints.
GroupUser Group = "user"
// GroupAdmin covers `/api/v1/admin/*` endpoints.
GroupAdmin Group = "admin"
// GroupInternal covers `/api/v1/internal/*` endpoints.
GroupInternal Group = "internal"
)
// Instruments holds the shared metric instruments used by every Group-scoped
// middleware. The instruments are constructed once per Meter; the
// per-middleware closure binds them to the right `group` attribute.
type Instruments struct {
requestsTotal metric.Int64Counter
requestDuration metric.Float64Histogram
}
// NewInstruments builds the shared metric instruments from meter. A nil meter
// returns nil instruments and disables metric emission.
func NewInstruments(meter metric.Meter) (*Instruments, error) {
if meter == nil {
return nil, nil
}
requestsTotal, err := meter.Int64Counter(
"http_requests_total",
metric.WithDescription("Number of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("1"),
)
if err != nil {
return nil, err
}
requestDuration, err := meter.Float64Histogram(
"http_request_duration_seconds",
metric.WithDescription("Duration of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("s"),
)
if err != nil {
return nil, err
}
return &Instruments{
requestsTotal: requestsTotal,
requestDuration: requestDuration,
}, nil
}
// Middleware returns a gin middleware that records request counters and
// duration histograms with the `group` attribute fixed to group. A nil
// instruments value yields a no-op middleware so that metric emission is
// strictly opt-in.
func Middleware(instruments *Instruments, group Group) gin.HandlerFunc {
if instruments == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
route := c.FullPath()
if route == "" {
route = "unmatched"
}
attrs := metric.WithAttributes(
attribute.String("group", string(group)),
attribute.String("method", c.Request.Method),
attribute.String("route", route),
attribute.String("status", strconv.Itoa(c.Writer.Status())),
)
instruments.requestsTotal.Add(c.Request.Context(), 1, attrs)
instruments.requestDuration.Record(c.Request.Context(), duration.Seconds(), attrs)
}
}
@@ -0,0 +1,38 @@
// Package panicrecovery converts unrecovered panics into a structured 500
// response and a single error-level log entry. It is wired exactly once at the
// top of the gin middleware chain.
package panicrecovery
import (
"net/http"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/requestid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Middleware returns a gin middleware that recovers from panics, logs the
// failure with trace fields, and writes the standard 500 envelope.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Any("panic", recovered),
}
if requestID, ok := requestid.FromGin(c); ok {
fields = append(fields, zap.String("request_id", requestID))
}
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
logger.Error("http handler panicked", fields...)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
})
}
@@ -0,0 +1,83 @@
// Package requestid carries a per-request identifier across the gin handler
// chain.
//
// The middleware reads the inbound `X-Request-ID` header, generates a UUIDv4
// when absent, stores the value on the gin context, and reflects it on the
// response. Downstream code retrieves the identifier through FromContext.
package requestid
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Header is the canonical case-correct header name carrying the request id.
const Header = "X-Request-ID"
// ginContextKey is the gin.Context key under which the resolved request id is
// stored. The value is a string. The key is exported in lowercase form so that
// it never collides with handler-level keys; consumers should prefer
// FromContext rather than reading the gin context directly.
const ginContextKey = "backend.request_id"
// requestIDContextKey is the unexported context.Context key used when the
// resolved request id is propagated outside gin (background goroutines,
// downstream client calls). The unexported value type prevents accidental
// collisions across packages.
type requestIDContextKey struct{}
// Middleware returns the gin middleware that resolves and propagates the
// request id.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := strings.TrimSpace(c.GetHeader(Header))
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(ginContextKey, requestID)
c.Writer.Header().Set(Header, requestID)
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), requestID))
c.Next()
}
}
// FromContext returns the request id stored on ctx by Middleware. The boolean
// reports whether an id was found. Consumers must always check the boolean
// before using the returned string.
func FromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(requestIDContextKey{}).(string)
if !ok || value == "" {
return "", false
}
return value, true
}
// FromGin returns the request id stored on the gin context by Middleware. The
// boolean reports whether an id was found.
func FromGin(c *gin.Context) (string, bool) {
if c == nil {
return "", false
}
raw, ok := c.Get(ginContextKey)
if !ok {
return "", false
}
value, ok := raw.(string)
if !ok || value == "" {
return "", false
}
return value, true
}
// WithValue stores requestID on ctx under the package-private context key.
// Exposed primarily for tests that build a context outside the middleware.
func WithValue(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDContextKey{}, requestID)
}
@@ -0,0 +1,70 @@
// Package userid extracts the calling user identifier from the trusted
// `X-User-ID` header injected by gateway and exposes it through the request
// context.
//
// Backend trusts the header value because the network segment between gateway
// and backend is the trust boundary (see `ARCHITECTURE.md` §15). The
// middleware therefore only validates the syntactic shape (UUID) and rejects
// malformed or absent values with the standard `400 invalid_request` envelope.
package userid
import (
"context"
"net/http"
"strings"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Header is the canonical case-correct header name carrying the trusted user
// id forwarded by gateway.
const Header = "X-User-ID"
// userIDContextKey is the unexported context key used to store the parsed
// user id. The unexported value type prevents accidental collisions with
// keys defined in unrelated packages.
type userIDContextKey struct{}
// Middleware returns the gin middleware that requires a syntactically valid
// `X-User-ID` header on every authenticated user request and stores the
// parsed UUID on the request context.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
raw := strings.TrimSpace(c.GetHeader(Header))
if raw == "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header is required")
return
}
userID, err := uuid.Parse(raw)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header must be a valid UUID")
return
}
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), userID))
c.Next()
}
}
// FromContext returns the user id stored on ctx by Middleware. The boolean
// reports whether a value was found.
func FromContext(ctx context.Context) (uuid.UUID, bool) {
if ctx == nil {
return uuid.Nil, false
}
value, ok := ctx.Value(userIDContextKey{}).(uuid.UUID)
if !ok {
return uuid.Nil, false
}
return value, true
}
// WithValue stores userID on ctx under the package-private context key.
// Exposed for tests that need to build a context outside the middleware.
func WithValue(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDContextKey{}, userID)
}