Files
glchat/internal/server/server.go
T
grendervill 8814fe7cf4 release: glchat 0.1.1
Версия — в одном месте: файл VERSION в корне, из него её берут Makefile,
deploy/install.sh (IMAGE_TAG) и desktop-обёртка (tauri.conf.json, Cargo.toml).

Публикация обновлений (scripts/desktop-release.sh):
- манифесты прошлых релизов удаляются: оставшийся <старая версия>.json
  перекрывал новый релиз — обёртка спрашивает манифест своей версии и видела
  «обновлений нет»;
- mktemp-каталог раскладки получает 755, каталоги на инстансе
  нормализуются: rsync -a переносил режим 0700 на корень каталога обновлений,
  и приложение не могло читать манифесты (404 при живых файлах);
- --remote работает без TTY и без sudo, если каталог обновлений записываем;
  опции ssh — в GLCHAT_SSH_OPTS;
- предупреждение при публикации версии старше уже опубликованной.

Сервер: HEAD /updates/... (chi не выводит его из GET) — размер и тип артефакта
без скачивания; тест TestUpdatesAnswersHead.

Приёмка на живом канале: 0.1.0 → 0.1.1 на стенде (скачивание, проверка
подписи, установка, перезапуск), /Applications/glchat.app = 0.1.1.
2026-09-26 20:14:25 +03:00

383 lines
16 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/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
// dmCallLimiter — 10 новых звонков в минуту на пользователя (Фаза 7):
// гудки в беседе не должны превращаться в спам.
dmCallLimiter *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),
dmCallLimiter: httpx.NewRateLimiterWindow(dmCallLimitCount, dmCallLimitWindow, dmCallLimitCount),
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.registerDMIconRoutes(apiRouter)
s.registerEmojiRoutes(s.api, apiRouter)
s.registerVoiceRoutes(s.api)
s.registerDMCallRoutes(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): обычная
// статика без сессии — обёртка проверяет обновления до входа в аккаунт.
// Обработчик берётся через поле, а не значением метода: каталог обновлений
// задаётся конфигом и в тестах подменяется до первого запроса. HEAD
// регистрируется отдельно (chi не выводит его из GET): по нему удобно
// проверять размер и тип артефакта, не скачивая его.
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.Head("/updates/*", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
router.Head("/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()