4fb3509128
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.
- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
исключает участника, отключает его от голосовой комнаты, пишет системное
сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
289 lines
11 KiB
Go
289 lines
11 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/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
|
||
// 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),
|
||
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.registerSocialRoutes(s.api)
|
||
s.registerEmojiRoutes(s.api, apiRouter)
|
||
s.registerVoiceRoutes(s.api)
|
||
s.registerSoundsRoutes(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()
|