ecd52c11d3
AGENT.md 7.14, начало Фазы 3:
- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
`guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
(микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
`LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
и флаги, запрет мьюта без прав, перемещение и выход.
254 lines
8.5 KiB
Go
254 lines
8.5 KiB
Go
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
|
||
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{},
|
||
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)
|
||
}
|
||
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) }
|
||
|
||
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()
|