Files
glchat/internal/server/server.go
T
grendervill c059cd9f51 feat(api): лимиты частоты запросов (AGENT.md 8.6)
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и
  подменяемым источником времени;
- login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на
  пользователя (по хэшу токена сессии) или на IP для анонимных запросов;
- превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки;
- тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке
  входа.
2026-09-19 21:51:47 +03:00

216 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()