Files
Ilia Denisov 362f92e520
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s
diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:02:46 +02:00

380 lines
15 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
UserMail *UserMailHandlers
UserSessions *UserSessionsHandlers
AdminAdminAccounts *AdminAdminAccountsHandlers
AdminUsers *AdminUsersHandlers
AdminGames *AdminGamesHandlers
AdminRuntimes *AdminRuntimesHandlers
AdminEngineVersions *AdminEngineVersionsHandlers
AdminMail *AdminMailHandlers
AdminDiplomail *AdminDiplomailHandlers
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.UserMail == nil {
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
}
if deps.UserSessions == nil {
deps.UserSessions = NewUserSessionsHandlers(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.AdminDiplomail == nil {
deps.AdminDiplomail = NewAdminDiplomailHandlers(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())
lobbyMail := lobbyGroup.Group("/mail")
lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts())
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/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
userMail := userGames.Group("/:game_id/mail")
userMail.POST("/messages", deps.UserMail.SendPersonal())
userMail.POST("/broadcast", deps.UserMail.SendBroadcast())
userMail.POST("/admin", deps.UserMail.SendAdmin())
userMail.GET("/messages/:message_id", deps.UserMail.Get())
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
userMail.GET("/inbox", deps.UserMail.Inbox())
userMail.GET("/sent", deps.UserMail.Sent())
userSessions := group.Group("/sessions")
userSessions.GET("", deps.UserSessions.List())
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
userSessions.POST("/:device_session_id/revoke", deps.UserSessions.Revoke())
}
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())
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
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())
mail.GET("/messages", deps.AdminDiplomail.List())
mail.POST("/broadcast", deps.AdminDiplomail.Broadcast())
mail.POST("/cleanup", deps.AdminDiplomail.Cleanup())
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.GET("/:device_session_id", deps.InternalSessions.Get())
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 ""
}
}