Files
glchat/internal/server/server.go
T
grendervill a48b865bc9 refactor: убрать вход через внешние провайдеры (OAuth) полностью
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного
человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего
входа только создаёт трение (регистрация приложений, redirect URI, модерация,
чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа
(WebAuthn).

Удалено:
- сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go
  и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек;
  отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta;
- клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки
  провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры;
- установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер
  чтения существующих значений переименован в existing_value — он остался нужен
  для VAPID_SUBJECT);
- зависимость golang.org/x/oauth2 (go mod tidy).

Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем —
она применена на стендах). Откат миграции возвращает структуру; тест
TestOAuthRemovalMigration проверяет накат, откат и повторный накат.

AGENT.md (локальный) помечает пункты про OAuth как отменённые.
2026-09-27 18:27:33 +03:00

380 lines
16 KiB
Go
Raw Permalink 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 ограничивает ручки входа по ключу доступа (Фаза 7):
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
passkeyLimiter *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),
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)
}
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()