package server import ( "context" "log/slog" "net/http" "strings" "sync" "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" "glchat/internal/sysinfo" "glchat/internal/voice" ) // 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 // Лимиты Фазы 2 (AGENT.md 8.6): сообщения, typing и поиск. messageLimiter *httpx.RateLimiter typingLimiter *httpx.RateLimiter searchLimiter *httpx.RateLimiter // editLimiter — 10 правок сообщений в минуту (AGENT.md 7.6). editLimiter *httpx.RateLimiter // soundboardLimiter — не чаще 3 звуков в 10 секунд на пользователя. soundboardLimiter *httpx.RateLimiter // inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6). inviteLimiter *httpx.RateLimiter // webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6). webhookLimiter *httpx.RateLimiter // slowmode — время последней отправки в комнату для режима медленной // отправки; словарь ограничен по размеру (AGENT.md 7.5). slowmodeMu sync.Mutex slowmode map[string]time.Time // voice — подписыватель токенов LiveKit (AGENT.md 7.14). voice *voice.TokenIssuer // voiceAdmin — RoomService для модерации на стороне SFU. voiceAdmin *voice.AdminClient // sysinfoPaths, cpuSampler и hostCPUSampler — источники живых метрик // дашборда инстанса: контейнер и вся машина. sysinfoPaths sysinfo.Paths cpuSampler *sysinfo.Sampler hostCPUSampler *sysinfo.Sampler // metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду). metricsLimiter *httpx.RateLimiter // health — кэш тяжёлых проверок инстанса. health healthCache // presence — время последнего обновления last_seen по пользователю. presenceMu sync.Mutex presence map[uint64]time.Time // webhookSeen — идентификаторы обработанных вебхуков LiveKit (идемпотентность). webhookMu sync.Mutex webhookSeen map[string]time.Time 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), // 5 сообщений за 5 секунд (burst 10), typing 1/3 c, поиск 10/мин. messageLimiter: httpx.NewRateLimiter(60, 10), typingLimiter: httpx.NewRateLimiter(20, 1), searchLimiter: httpx.NewRateLimiter(10, 10), editLimiter: httpx.NewRateLimiter(10, 10), soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3), inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit), slowmode: map[string]time.Time{}, presence: map[uint64]time.Time{}, webhookSeen: map[string]time.Time{}, // Дашборд опрашивает метрики раз в секунду: 120/мин с запасом. metricsLimiter: httpx.NewRateLimiter(120, 30), sysinfoPaths: sysinfo.DefaultPaths(), voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL), } s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths) s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths) 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) // Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go). apiRouter.Use(s.apiLimiter.MiddlewareExcept(apiRateLimitKey, metricsPath)) 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) s.registerBanRoutes(s.api) s.registerMetricsRoutes(s.api) s.registerMessageRoutes(s.api) s.registerInviteRoutes(s.api) s.registerFileRoutes(s.api, apiRouter) s.registerGuildAppearanceRoutes(apiRouter) s.registerSocialRoutes(s.api) s.registerEmojiRoutes(s.api, apiRouter) s.registerVoiceRoutes(s.api) s.registerSoundsRoutes(s.api, apiRouter) s.registerWebhookRoutes(s.api, apiRouter) s.registerCosmeticRoutes(s.api, apiRouter) s.registerVoiceWebhook(apiRouter) } 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) }) } if s.store != nil { s.registerFileDownload(router) } 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) } // StartVoiceWatchdog запускает периодическую очистку голосовых состояний. func (s *Server) StartVoiceWatchdog(ctx context.Context, interval time.Duration) { s.startVoiceWatchdog(ctx, interval) } 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()