346 lines
14 KiB
Go
346 lines
14 KiB
Go
// Package server hosts the backend HTTP listener and the route
|
|
// configuration that wires the documented `backend/openapi.yaml`
|
|
// contract against the per-domain handler sets.
|
|
//
|
|
// router.go is the single place where route groups, group-scoped
|
|
// middleware, and per-domain handlers are mounted. Domain handlers
|
|
// hold their own Service references; the routing layout is stable.
|
|
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"sync"
|
|
|
|
"galaxy/backend/internal/server/httperr"
|
|
"galaxy/backend/internal/server/middleware/basicauth"
|
|
"galaxy/backend/internal/server/middleware/geocounter"
|
|
"galaxy/backend/internal/server/middleware/logging"
|
|
"galaxy/backend/internal/server/middleware/metrics"
|
|
"galaxy/backend/internal/server/middleware/panicrecovery"
|
|
"galaxy/backend/internal/server/middleware/requestid"
|
|
"galaxy/backend/internal/server/middleware/userid"
|
|
"galaxy/backend/internal/telemetry"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
// otelServerName is the operation-name attribute attached to spans
|
|
// produced by otelgin.
|
|
otelServerName = "galaxy-backend"
|
|
|
|
// adminBasicAuthRealm is the realm advertised on `WWW-Authenticate`
|
|
// responses from the admin surface.
|
|
adminBasicAuthRealm = "galaxy-admin"
|
|
)
|
|
|
|
var configureGinModeOnce sync.Once
|
|
|
|
// RouterDependencies aggregates every collaborator required to build the
|
|
// backend HTTP handler chain.
|
|
//
|
|
// Logger, Telemetry, and Ready come from the process bootstrap.
|
|
// AdminVerifier gates the admin surface; production wires
|
|
// `*admin.Service`. The handler-set fields are allowed to be nil —
|
|
// NewRouter substitutes a freshly-constructed placeholder set so
|
|
// callers can supply only the slices they want to override.
|
|
type RouterDependencies struct {
|
|
Logger *zap.Logger
|
|
Telemetry *telemetry.Runtime
|
|
Ready func() bool
|
|
AdminVerifier basicauth.Verifier
|
|
|
|
// GeoCounter, when non-nil, is mounted as middleware on the
|
|
// `/api/v1/user/*` route group so that every authenticated request
|
|
// dispatches a fire-and-forget counter increment. A nil value
|
|
// leaves the route group untouched, which keeps existing tests
|
|
// that build the router without geo wiring working as before.
|
|
GeoCounter geocounter.Service
|
|
|
|
PublicAuth *PublicAuthHandlers
|
|
UserAccount *UserAccountHandlers
|
|
UserLobbyGames *UserLobbyGamesHandlers
|
|
UserLobbyApplications *UserLobbyApplicationsHandlers
|
|
UserLobbyInvites *UserLobbyInvitesHandlers
|
|
UserLobbyMemberships *UserLobbyMembershipsHandlers
|
|
UserLobbyMy *UserLobbyMyHandlers
|
|
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
|
UserGames *UserGamesHandlers
|
|
AdminAdminAccounts *AdminAdminAccountsHandlers
|
|
AdminUsers *AdminUsersHandlers
|
|
AdminGames *AdminGamesHandlers
|
|
AdminRuntimes *AdminRuntimesHandlers
|
|
AdminEngineVersions *AdminEngineVersionsHandlers
|
|
AdminMail *AdminMailHandlers
|
|
AdminNotifications *AdminNotificationsHandlers
|
|
AdminGeo *AdminGeoHandlers
|
|
InternalSessions *InternalSessionsHandlers
|
|
InternalUsers *InternalUsersHandlers
|
|
}
|
|
|
|
// NewRouter constructs the backend gin engine wired with the documented
|
|
// middleware chain and every placeholder route from `backend/openapi.yaml`.
|
|
// The returned handler is safe to pass into Server.NewServer.
|
|
func NewRouter(deps RouterDependencies) (http.Handler, error) {
|
|
configureGinModeOnce.Do(func() {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
})
|
|
|
|
if deps.Logger == nil {
|
|
deps.Logger = zap.NewNop()
|
|
}
|
|
|
|
deps = withDefaultHandlers(deps)
|
|
|
|
logger := deps.Logger.Named("http")
|
|
|
|
var instruments *metrics.Instruments
|
|
if deps.Telemetry != nil {
|
|
var err error
|
|
instruments, err = metrics.NewInstruments(deps.Telemetry.MeterProvider().Meter(otelServerName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
router := gin.New()
|
|
router.HandleMethodNotAllowed = true
|
|
|
|
router.Use(requestid.Middleware())
|
|
router.Use(panicrecovery.Middleware(logger))
|
|
router.Use(otelgin.Middleware(otelServerName))
|
|
router.Use(logging.Middleware(logger))
|
|
|
|
router.GET("/healthz", metrics.Middleware(instruments, metrics.GroupProbes), handleHealthz)
|
|
router.GET("/readyz", metrics.Middleware(instruments, metrics.GroupProbes), handleReadyz(deps.Ready))
|
|
|
|
registerPublicRoutes(router, instruments, deps)
|
|
registerUserRoutes(router, instruments, deps)
|
|
registerAdminRoutes(router, instruments, deps)
|
|
registerInternalRoutes(router, instruments, deps)
|
|
|
|
router.NoMethod(func(c *gin.Context) {
|
|
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
|
c.Header("Allow", allow)
|
|
}
|
|
httperr.Abort(c, http.StatusMethodNotAllowed, httperr.CodeMethodNotAllowed, "request method is not allowed for this route")
|
|
})
|
|
router.NoRoute(func(c *gin.Context) {
|
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
|
})
|
|
|
|
return router, nil
|
|
}
|
|
|
|
func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
|
if deps.PublicAuth == nil {
|
|
deps.PublicAuth = NewPublicAuthHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserAccount == nil {
|
|
deps.UserAccount = NewUserAccountHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyGames == nil {
|
|
deps.UserLobbyGames = NewUserLobbyGamesHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyApplications == nil {
|
|
deps.UserLobbyApplications = NewUserLobbyApplicationsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyInvites == nil {
|
|
deps.UserLobbyInvites = NewUserLobbyInvitesHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyMemberships == nil {
|
|
deps.UserLobbyMemberships = NewUserLobbyMembershipsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyMy == nil {
|
|
deps.UserLobbyMy = NewUserLobbyMyHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserLobbyRaceNames == nil {
|
|
deps.UserLobbyRaceNames = NewUserLobbyRaceNamesHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.UserGames == nil {
|
|
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
|
}
|
|
if deps.AdminAdminAccounts == nil {
|
|
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminUsers == nil {
|
|
deps.AdminUsers = NewAdminUsersHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminGames == nil {
|
|
deps.AdminGames = NewAdminGamesHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminRuntimes == nil {
|
|
deps.AdminRuntimes = NewAdminRuntimesHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminEngineVersions == nil {
|
|
deps.AdminEngineVersions = NewAdminEngineVersionsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminMail == nil {
|
|
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminNotifications == nil {
|
|
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.AdminGeo == nil {
|
|
deps.AdminGeo = NewAdminGeoHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.InternalSessions == nil {
|
|
deps.InternalSessions = NewInternalSessionsHandlers(nil, deps.Logger)
|
|
}
|
|
if deps.InternalUsers == nil {
|
|
deps.InternalUsers = NewInternalUsersHandlers(nil, deps.Logger)
|
|
}
|
|
return deps
|
|
}
|
|
|
|
func registerPublicRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
|
group := router.Group("/api/v1/public")
|
|
group.Use(metrics.Middleware(instruments, metrics.GroupPublic))
|
|
|
|
auth := group.Group("/auth")
|
|
auth.POST("/send-email-code", deps.PublicAuth.SendEmailCode())
|
|
auth.POST("/confirm-email-code", deps.PublicAuth.ConfirmEmailCode())
|
|
}
|
|
|
|
func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
|
group := router.Group("/api/v1/user")
|
|
group.Use(metrics.Middleware(instruments, metrics.GroupUser))
|
|
group.Use(userid.Middleware())
|
|
if deps.GeoCounter != nil {
|
|
group.Use(geocounter.Middleware(deps.GeoCounter))
|
|
}
|
|
|
|
account := group.Group("/account")
|
|
account.GET("", deps.UserAccount.Get())
|
|
account.PATCH("/profile", deps.UserAccount.UpdateProfile())
|
|
account.PATCH("/settings", deps.UserAccount.UpdateSettings())
|
|
account.POST("/delete", deps.UserAccount.Delete())
|
|
|
|
lobbyGroup := group.Group("/lobby")
|
|
games := lobbyGroup.Group("/games")
|
|
games.GET("", deps.UserLobbyGames.List())
|
|
games.POST("", deps.UserLobbyGames.Create())
|
|
games.GET("/:game_id", deps.UserLobbyGames.Get())
|
|
games.PATCH("/:game_id", deps.UserLobbyGames.Update())
|
|
games.POST("/:game_id/open-enrollment", deps.UserLobbyGames.OpenEnrollment())
|
|
games.POST("/:game_id/ready-to-start", deps.UserLobbyGames.ReadyToStart())
|
|
games.POST("/:game_id/start", deps.UserLobbyGames.Start())
|
|
games.POST("/:game_id/pause", deps.UserLobbyGames.Pause())
|
|
games.POST("/:game_id/resume", deps.UserLobbyGames.Resume())
|
|
games.POST("/:game_id/cancel", deps.UserLobbyGames.Cancel())
|
|
games.POST("/:game_id/retry-start", deps.UserLobbyGames.RetryStart())
|
|
|
|
games.POST("/:game_id/applications", deps.UserLobbyApplications.Submit())
|
|
games.POST("/:game_id/applications/:application_id/approve", deps.UserLobbyApplications.Approve())
|
|
games.POST("/:game_id/applications/:application_id/reject", deps.UserLobbyApplications.Reject())
|
|
|
|
games.POST("/:game_id/invites", deps.UserLobbyInvites.Issue())
|
|
games.POST("/:game_id/invites/:invite_id/redeem", deps.UserLobbyInvites.Redeem())
|
|
games.POST("/:game_id/invites/:invite_id/decline", deps.UserLobbyInvites.Decline())
|
|
games.POST("/:game_id/invites/:invite_id/revoke", deps.UserLobbyInvites.Revoke())
|
|
|
|
games.GET("/:game_id/memberships", deps.UserLobbyMemberships.List())
|
|
games.POST("/:game_id/memberships/:membership_id/remove", deps.UserLobbyMemberships.Remove())
|
|
games.POST("/:game_id/memberships/:membership_id/block", deps.UserLobbyMemberships.Block())
|
|
|
|
my := lobbyGroup.Group("/my")
|
|
my.GET("/games", deps.UserLobbyMy.Games())
|
|
my.GET("/applications", deps.UserLobbyMy.Applications())
|
|
my.GET("/invites", deps.UserLobbyMy.Invites())
|
|
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
|
|
|
raceNames := lobbyGroup.Group("/race-names")
|
|
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
|
|
|
userGames := group.Group("/games")
|
|
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
|
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
|
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
|
}
|
|
|
|
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
|
group := router.Group("/api/v1/admin")
|
|
group.Use(metrics.Middleware(instruments, metrics.GroupAdmin))
|
|
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
|
|
|
|
adminAccounts := group.Group("/admin-accounts")
|
|
adminAccounts.GET("", deps.AdminAdminAccounts.List())
|
|
adminAccounts.POST("", deps.AdminAdminAccounts.Create())
|
|
adminAccounts.GET("/:username", deps.AdminAdminAccounts.Get())
|
|
adminAccounts.POST("/:username/disable", deps.AdminAdminAccounts.Disable())
|
|
adminAccounts.POST("/:username/enable", deps.AdminAdminAccounts.Enable())
|
|
adminAccounts.POST("/:username/reset-password", deps.AdminAdminAccounts.ResetPassword())
|
|
|
|
users := group.Group("/users")
|
|
users.GET("", deps.AdminUsers.List())
|
|
users.GET("/:user_id", deps.AdminUsers.Get())
|
|
users.POST("/:user_id/sanctions", deps.AdminUsers.AddSanction())
|
|
users.POST("/:user_id/limits", deps.AdminUsers.AddLimit())
|
|
users.POST("/:user_id/entitlements", deps.AdminUsers.AddEntitlement())
|
|
users.POST("/:user_id/soft-delete", deps.AdminUsers.SoftDelete())
|
|
|
|
games := group.Group("/games")
|
|
games.GET("", deps.AdminGames.List())
|
|
games.POST("", deps.AdminGames.Create())
|
|
games.GET("/:game_id", deps.AdminGames.Get())
|
|
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
|
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
|
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
|
|
|
runtimes := group.Group("/runtimes")
|
|
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
|
runtimes.POST("/:game_id/restart", deps.AdminRuntimes.Restart())
|
|
runtimes.POST("/:game_id/patch", deps.AdminRuntimes.Patch())
|
|
runtimes.POST("/:game_id/force-next-turn", deps.AdminRuntimes.ForceNextTurn())
|
|
|
|
engineVersions := group.Group("/engine-versions")
|
|
engineVersions.GET("", deps.AdminEngineVersions.List())
|
|
engineVersions.POST("", deps.AdminEngineVersions.Create())
|
|
engineVersions.PATCH("/:id", deps.AdminEngineVersions.Update())
|
|
engineVersions.POST("/:id/disable", deps.AdminEngineVersions.Disable())
|
|
|
|
mail := group.Group("/mail")
|
|
mail.GET("/deliveries", deps.AdminMail.ListDeliveries())
|
|
mail.GET("/deliveries/:delivery_id", deps.AdminMail.GetDelivery())
|
|
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
|
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
|
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
|
|
|
notifications := group.Group("/notifications")
|
|
notifications.GET("", deps.AdminNotifications.List())
|
|
notifications.GET("/dead-letters", deps.AdminNotifications.ListDeadLetters())
|
|
notifications.GET("/malformed", deps.AdminNotifications.ListMalformed())
|
|
notifications.GET("/:notification_id", deps.AdminNotifications.Get())
|
|
|
|
geo := group.Group("/geo")
|
|
geo.GET("/users/:user_id/countries", deps.AdminGeo.ListUserCountries())
|
|
}
|
|
|
|
func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
|
group := router.Group("/api/v1/internal")
|
|
group.Use(metrics.Middleware(instruments, metrics.GroupInternal))
|
|
|
|
sessions := group.Group("/sessions")
|
|
sessions.POST("/users/:user_id/revoke-all", deps.InternalSessions.RevokeAllForUser())
|
|
sessions.GET("/:device_session_id", deps.InternalSessions.Get())
|
|
sessions.POST("/:device_session_id/revoke", deps.InternalSessions.Revoke())
|
|
|
|
users := group.Group("/users")
|
|
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
|
}
|
|
|
|
// allowedMethodsForPath returns the comma-separated list of methods
|
|
// the gin router accepts on requestPath. Only the probe paths declare
|
|
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
|
// on `/healthz` and `/readyz`. Other endpoints fall through to NoRoute.
|
|
func allowedMethodsForPath(requestPath string) string {
|
|
switch requestPath {
|
|
case "/healthz", "/readyz":
|
|
return http.MethodGet
|
|
default:
|
|
return ""
|
|
}
|
|
}
|