3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
373 lines
16 KiB
Go
373 lines
16 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/push"
|
||
"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
|
||
// uploadLimiter и uploadDailyLimiter — 10 загрузок в минуту и 100 в сутки
|
||
// на пользователя (AGENT.md 8.6).
|
||
uploadLimiter *httpx.RateLimiter
|
||
uploadDailyLimiter *httpx.RateLimiter
|
||
// reactionLimiter — 20 реакций в минуту на пользователя.
|
||
reactionLimiter *httpx.RateLimiter
|
||
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7):
|
||
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
||
passkeyLimiter *httpx.RateLimiter
|
||
oauthLimiter *httpx.RateLimiter
|
||
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
|
||
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
|
||
pushLimiter *httpx.RateLimiter
|
||
pushNotifyLimiter *httpx.RateLimiter
|
||
// push отправляет Web Push; nil, если ключи не настроены.
|
||
push pushSender
|
||
// unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter
|
||
// ограничивает новые загрузки (кэш лимит не расходует).
|
||
unfurl *linkPreviewer
|
||
unfurlLimiter *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
|
||
// auditExportLimiter — выгрузка журнала инстанса: тяжёлый ответ, поэтому
|
||
// 5 экспортов в минуту на администратора (AGENT.md 7.18, 8.6).
|
||
auditExportLimiter *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
|
||
// updates отдаёт манифесты автообновления desktop-обёртки (docs/client-tauri.md §4).
|
||
updates *updatesHandler
|
||
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),
|
||
updates: newUpdatesHandler(cfg.UpdatesDir),
|
||
// 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),
|
||
// Второй аргумент NewRateLimiter — запас (burst), а не окно.
|
||
uploadLimiter: httpx.NewRateLimiter(10, 10),
|
||
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
|
||
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
||
// ответы лимит не тратят).
|
||
unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20),
|
||
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
||
// упоминания не должны заваливать устройство.
|
||
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
||
slowmode: map[string]time.Time{},
|
||
presence: map[uint64]time.Time{},
|
||
webhookSeen: map[string]time.Time{},
|
||
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
|
||
metricsLimiter: httpx.NewRateLimiter(120, 30),
|
||
// Экспорт журнала — редкое и тяжёлое действие: 5 раз в минуту.
|
||
auditExportLimiter: httpx.NewRateLimiterWindow(5, time.Minute, 5),
|
||
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))
|
||
}
|
||
// Превью ссылок (Фаза 7): загрузчик создаётся всегда, а выключенный
|
||
// unfurl просто отвечает `unfurl.disabled` и в интернет не ходит.
|
||
if deps.Store != nil {
|
||
s.unfurl = newLinkPreviewer(cfg, deps.Store, logger)
|
||
}
|
||
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
||
// подняться — пишем предупреждение и работаем без push.
|
||
if deps.Store != nil {
|
||
sender, err := push.New(cfg, deps.Store, logger)
|
||
if err != nil {
|
||
logger.Error("web push is disabled", slog.Any("error", err))
|
||
} else {
|
||
s.push = sender
|
||
}
|
||
}
|
||
|
||
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))
|
||
// Строгая проверка Origin для state-changing запросов (AGENT.md 9.7).
|
||
apiRouter.Use(httpx.OriginCheck(cfg.AllowedOrigins()))
|
||
s.api = s.registerAPI(apiRouter)
|
||
s.registerMetaRoutes(s.api)
|
||
s.registerAuthRoutes(apiRouter)
|
||
if deps.Auth != nil {
|
||
s.registerPasskeyRoutes(apiRouter)
|
||
s.registerOAuthRoutes(apiRouter)
|
||
}
|
||
if deps.Store != nil {
|
||
s.registerUserRoutes(s.api)
|
||
s.registerGuildRoutes(s.api)
|
||
s.registerInstanceRoutes(s.api)
|
||
s.registerInstanceExportRoutes(apiRouter)
|
||
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.registerChannelBackgroundRoutes(apiRouter)
|
||
s.registerInviteBackgroundRoutes(apiRouter)
|
||
s.registerVoiceWebhook(apiRouter)
|
||
s.registerPushRoutes(s.api)
|
||
s.registerUnfurlRoutes(s.api)
|
||
}
|
||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||
})
|
||
s.registerRoutes(router)
|
||
|
||
handler := httpx.Chain(router,
|
||
httpx.SecurityHeaders(httpx.SecurityOptions{
|
||
FilesDomain: cfg.FilesDomain,
|
||
LiveKitURL: cfg.LiveKitURL,
|
||
}),
|
||
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)
|
||
}
|
||
|
||
// Манифест автообновления desktop-обёртки (docs/client-tauri.md §4): обычная
|
||
// статика без сессии — обёртка проверяет обновления до входа в аккаунт.
|
||
// Обработчик берётся через поле, а не значением метода: каталог обновлений
|
||
// задаётся конфигом и в тестах подменяется до первого запроса.
|
||
router.Get("/updates/*", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
|
||
router.Get("/updates", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
|
||
|
||
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()
|