c059cd9f51
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и подменяемым источником времени; - login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на пользователя (по хэшу токена сессии) или на IP для анонимных запросов; - превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки; - тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке входа.
216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/danielgtaylor/huma/v2"
|
||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"glchat/internal/auth"
|
||
"glchat/internal/config"
|
||
"glchat/internal/crypto"
|
||
"glchat/internal/database"
|
||
"glchat/internal/gateway"
|
||
"glchat/internal/httpx"
|
||
"glchat/internal/meta"
|
||
"glchat/internal/permissions"
|
||
"glchat/internal/source"
|
||
"glchat/internal/store"
|
||
)
|
||
|
||
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
||
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
|
||
type Deps struct {
|
||
Store *store.Store
|
||
Auth *auth.Service
|
||
Gateway *gateway.Service
|
||
// Permissions — общий с Gateway калькулятор прав (необязателен: если не
|
||
// передан, сервер создаёт собственный).
|
||
Permissions *permissions.Calculator
|
||
}
|
||
|
||
type Server struct {
|
||
cfg config.Config
|
||
db *database.DB
|
||
store *store.Store
|
||
auth *auth.Service
|
||
gateway *gateway.Service
|
||
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
||
perms *permissions.Calculator
|
||
// authLimiter ограничивает login/register по IP, apiLimiter — API целиком
|
||
// (AGENT.md 8.6).
|
||
authLimiter *httpx.RateLimiter
|
||
apiLimiter *httpx.RateLimiter
|
||
logger *slog.Logger
|
||
http *http.Server
|
||
static *staticHandler
|
||
api huma.API
|
||
}
|
||
|
||
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
||
s := &Server{
|
||
cfg: cfg,
|
||
db: db,
|
||
store: deps.Store,
|
||
auth: deps.Auth,
|
||
gateway: deps.Gateway,
|
||
logger: logger,
|
||
static: newStaticHandler(cfg.WebRoot),
|
||
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
|
||
// пользователя/IP — дефолты AGENT.md 8.6.
|
||
authLimiter: httpx.NewRateLimiter(5, 5),
|
||
apiLimiter: httpx.NewRateLimiter(120, 60),
|
||
}
|
||
switch {
|
||
case deps.Permissions != nil:
|
||
s.perms = deps.Permissions
|
||
case deps.Store != nil:
|
||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||
}
|
||
|
||
router := chi.NewRouter()
|
||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
||
apiRouter.Use(s.sessionContext)
|
||
apiRouter.Use(s.apiLimiter.Middleware(apiRateLimitKey))
|
||
s.api = s.registerAPI(apiRouter)
|
||
s.registerMetaRoutes(s.api)
|
||
s.registerAuthRoutes(apiRouter)
|
||
if deps.Store != nil {
|
||
s.registerUserRoutes(s.api)
|
||
s.registerGuildRoutes(s.api)
|
||
s.registerInstanceRoutes(s.api)
|
||
}
|
||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||
})
|
||
s.registerRoutes(router)
|
||
|
||
handler := httpx.Chain(router,
|
||
httpx.SecurityHeaders(cfg.FilesDomain),
|
||
httpx.RequestID,
|
||
httpx.RequestInfoMiddleware,
|
||
httpx.Logger(logger),
|
||
httpx.Recoverer(logger),
|
||
httpx.JSONBodyLimit(1<<20),
|
||
)
|
||
s.http = &http.Server{
|
||
Addr: cfg.ListenAddr,
|
||
Handler: handler,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 30 * time.Second,
|
||
WriteTimeout: 60 * time.Second,
|
||
IdleTimeout: 120 * time.Second,
|
||
MaxHeaderBytes: 1 << 16,
|
||
}
|
||
return s
|
||
}
|
||
|
||
// registerAPI создаёт huma-API: OpenAPI 3.1 и TS-типы выводятся из Go-типов
|
||
// (AGENT.md 8.1, решение D-006).
|
||
func (s *Server) registerAPI(router chi.Router) huma.API {
|
||
cfg := huma.DefaultConfig("glchat API", s.cfg.Version)
|
||
cfg.Info.Description = "Self-hosted платформа общения: REST /api/v1 и WebSocket Gateway."
|
||
cfg.Info.License = &huma.License{Name: "AGPL-3.0-or-later", Identifier: "AGPL-3.0-or-later"}
|
||
cfg.Servers = []*huma.Server{{URL: "/api/v1"}}
|
||
cfg.OpenAPIPath = "/openapi"
|
||
cfg.DocsPath = "/docs"
|
||
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||
"sessionCookie": {Type: "apiKey", In: "cookie", Name: sessionCookieName},
|
||
"bearerAuth": {Type: "http", Scheme: "bearer"},
|
||
}
|
||
return humachi.New(router, cfg)
|
||
}
|
||
|
||
func (s *Server) registerRoutes(router chi.Router) {
|
||
router.Get("/healthz", s.handleHealthz)
|
||
router.Get("/readyz", s.handleReadyz)
|
||
if s.gateway != nil {
|
||
router.Handle("/gateway", s.gateway.Handler())
|
||
} else {
|
||
router.HandleFunc("/gateway", func(w http.ResponseWriter, _ *http.Request) {
|
||
writeAPIError(w, auth.ErrSessionExpired)
|
||
})
|
||
}
|
||
|
||
router.NotFound(s.handleFallback)
|
||
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||
})
|
||
}
|
||
|
||
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
||
|
||
func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() }
|
||
|
||
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
|
||
|
||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||
"status": "ok",
|
||
"version": s.cfg.Version,
|
||
"uptime": time.Since(startedAt).Round(time.Second).String(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
checks := map[string]string{"database": "ok"}
|
||
ready := true
|
||
if err := s.db.Ready(ctx); err != nil {
|
||
checks["database"] = "failed"
|
||
ready = false
|
||
s.logger.WarnContext(ctx, "readiness check failed", slog.Any("error", err))
|
||
}
|
||
status := http.StatusOK
|
||
state := "ready"
|
||
if !ready {
|
||
status = http.StatusServiceUnavailable
|
||
state = "unavailable"
|
||
}
|
||
httpx.WriteJSON(w, status, map[string]any{
|
||
"status": state,
|
||
"version": s.cfg.Version,
|
||
"checks": checks,
|
||
})
|
||
}
|
||
|
||
type metaOutput struct {
|
||
Body meta.Response
|
||
}
|
||
|
||
func (s *Server) registerMetaRoutes(api huma.API) {
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "getMeta",
|
||
Method: http.MethodGet,
|
||
Path: "/meta",
|
||
Summary: "Метаданные инстанса, версия API и флаги функций",
|
||
Tags: []string{"Meta"},
|
||
}, func(_ context.Context, _ *struct{}) (*metaOutput, error) {
|
||
return &metaOutput{Body: meta.New(s.cfg)}, nil
|
||
})
|
||
}
|
||
|
||
// apiRateLimitKey ключует лимит по пользователю, а для анонимных запросов — по IP.
|
||
func apiRateLimitKey(r *http.Request) string {
|
||
if token := sessionToken(r); token != "" {
|
||
return "session:" + crypto.HashToken(token)
|
||
}
|
||
return "ip:" + httpx.ClientIP(r, nil)
|
||
}
|
||
|
||
func normalizeBearer(value string) string {
|
||
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||
return strings.TrimSpace(value[7:])
|
||
}
|
||
return strings.TrimSpace(value)
|
||
}
|
||
|
||
var startedAt = time.Now()
|