Files
glchat/internal/server/server.go
T
grendervill e9455ab6fd feat(voice): вебхуки LiveKit и сторож голосовых состояний
AGENT.md 7.14:

- POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным
  API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе —
  503; обработка идемпотентна по event.id (повторная доставка → duplicate);
- события: participant_joined восстанавливает состояние после рестарта
  приложения, participant_left и participant_connection_aborted убирают
  участника, room_finished очищает комнату целиком, track_published и
  track_unpublished обновляют флаги камеры и демонстрации экрана; каждое
  изменение рассылает VOICE_STATE_UPDATE участникам сервера;
- сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений
  дольше шести часов (сбои без вебхука) и уведомляет клиентов;
- тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный
  цикл событий SFU и идемпотентность повторной доставки.
2026-09-20 18:31:51 +03:00

264 lines
9.0 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"
"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/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
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
inviteLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
slowmode map[string]time.Time
// voice — подписыватель токенов LiveKit (AGENT.md 7.14).
voice *voice.TokenIssuer
// 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),
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour),
}
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)
s.registerMessageRoutes(s.api)
s.registerInviteRoutes(s.api)
s.registerFileRoutes(s.api, apiRouter)
s.registerSocialRoutes(s.api)
s.registerEmojiRoutes(s.api, apiRouter)
s.registerVoiceRoutes(s.api)
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()