2026-09-19 18:20:56 +03:00
|
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"log/slog"
|
|
|
|
|
|
"net/http"
|
2026-09-19 21:36:00 +03:00
|
|
|
|
"strings"
|
2026-09-19 23:32:11 +03:00
|
|
|
|
"sync"
|
2026-09-19 18:20:56 +03:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
|
|
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
|
|
|
|
|
|
|
"glchat/internal/auth"
|
2026-09-19 18:20:56 +03:00
|
|
|
|
"glchat/internal/config"
|
2026-09-19 21:51:47 +03:00
|
|
|
|
"glchat/internal/crypto"
|
2026-09-19 18:20:56 +03:00
|
|
|
|
"glchat/internal/database"
|
2026-09-19 21:42:41 +03:00
|
|
|
|
"glchat/internal/gateway"
|
2026-09-19 18:20:56 +03:00
|
|
|
|
"glchat/internal/httpx"
|
|
|
|
|
|
"glchat/internal/meta"
|
2026-09-19 21:50:06 +03:00
|
|
|
|
"glchat/internal/permissions"
|
2026-09-26 16:14:20 +03:00
|
|
|
|
"glchat/internal/push"
|
2026-09-19 21:50:06 +03:00
|
|
|
|
"glchat/internal/source"
|
2026-09-19 21:36:00 +03:00
|
|
|
|
"glchat/internal/store"
|
2026-09-20 21:26:17 +03:00
|
|
|
|
"glchat/internal/sysinfo"
|
2026-09-20 18:04:59 +03:00
|
|
|
|
"glchat/internal/voice"
|
2026-09-19 18:20:56 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
|
|
|
|
|
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
|
|
|
|
|
|
type Deps struct {
|
2026-09-19 21:42:41 +03:00
|
|
|
|
Store *store.Store
|
|
|
|
|
|
Auth *auth.Service
|
|
|
|
|
|
Gateway *gateway.Service
|
2026-09-19 21:50:06 +03:00
|
|
|
|
// Permissions — общий с Gateway калькулятор прав (необязателен: если не
|
|
|
|
|
|
// передан, сервер создаёт собственный).
|
|
|
|
|
|
Permissions *permissions.Calculator
|
2026-09-19 21:36:00 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 18:20:56 +03:00
|
|
|
|
type Server struct {
|
2026-09-19 21:42:41 +03:00
|
|
|
|
cfg config.Config
|
|
|
|
|
|
db *database.DB
|
|
|
|
|
|
store *store.Store
|
|
|
|
|
|
auth *auth.Service
|
|
|
|
|
|
gateway *gateway.Service
|
2026-09-19 21:50:06 +03:00
|
|
|
|
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
2026-09-19 21:51:47 +03:00
|
|
|
|
perms *permissions.Calculator
|
|
|
|
|
|
// authLimiter ограничивает login/register по IP, apiLimiter — API целиком
|
|
|
|
|
|
// (AGENT.md 8.6).
|
|
|
|
|
|
authLimiter *httpx.RateLimiter
|
|
|
|
|
|
apiLimiter *httpx.RateLimiter
|
2026-09-19 23:32:11 +03:00
|
|
|
|
// Лимиты Фазы 2 (AGENT.md 8.6): сообщения, typing и поиск.
|
|
|
|
|
|
messageLimiter *httpx.RateLimiter
|
|
|
|
|
|
typingLimiter *httpx.RateLimiter
|
|
|
|
|
|
searchLimiter *httpx.RateLimiter
|
2026-09-20 11:32:50 +03:00
|
|
|
|
// editLimiter — 10 правок сообщений в минуту (AGENT.md 7.6).
|
|
|
|
|
|
editLimiter *httpx.RateLimiter
|
2026-09-20 19:01:14 +03:00
|
|
|
|
// soundboardLimiter — не чаще 3 звуков в 10 секунд на пользователя.
|
|
|
|
|
|
soundboardLimiter *httpx.RateLimiter
|
2026-09-19 23:37:02 +03:00
|
|
|
|
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
|
|
|
|
|
inviteLimiter *httpx.RateLimiter
|
2026-09-21 00:26:50 +03:00
|
|
|
|
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
|
|
|
|
|
webhookLimiter *httpx.RateLimiter
|
2026-09-22 00:40:10 +03:00
|
|
|
|
// uploadLimiter и uploadDailyLimiter — 10 загрузок в минуту и 100 в сутки
|
|
|
|
|
|
// на пользователя (AGENT.md 8.6).
|
|
|
|
|
|
uploadLimiter *httpx.RateLimiter
|
|
|
|
|
|
uploadDailyLimiter *httpx.RateLimiter
|
|
|
|
|
|
// reactionLimiter — 20 реакций в минуту на пользователя.
|
|
|
|
|
|
reactionLimiter *httpx.RateLimiter
|
2026-09-26 15:13:24 +03:00
|
|
|
|
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7):
|
|
|
|
|
|
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
2026-09-26 15:12:43 +03:00
|
|
|
|
passkeyLimiter *httpx.RateLimiter
|
2026-09-26 15:13:24 +03:00
|
|
|
|
oauthLimiter *httpx.RateLimiter
|
2026-09-26 16:14:20 +03:00
|
|
|
|
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
|
|
|
|
|
|
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
|
|
|
|
|
|
pushLimiter *httpx.RateLimiter
|
|
|
|
|
|
pushNotifyLimiter *httpx.RateLimiter
|
|
|
|
|
|
// push отправляет Web Push; nil, если ключи не настроены.
|
|
|
|
|
|
push pushSender
|
2026-09-26 16:15:00 +03:00
|
|
|
|
// unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter
|
|
|
|
|
|
// ограничивает новые загрузки (кэш лимит не расходует).
|
|
|
|
|
|
unfurl *linkPreviewer
|
|
|
|
|
|
unfurlLimiter *httpx.RateLimiter
|
2026-09-19 23:32:11 +03:00
|
|
|
|
// slowmode — время последней отправки в комнату для режима медленной
|
|
|
|
|
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
|
|
|
|
|
slowmodeMu sync.Mutex
|
|
|
|
|
|
slowmode map[string]time.Time
|
2026-09-20 18:04:59 +03:00
|
|
|
|
// voice — подписыватель токенов LiveKit (AGENT.md 7.14).
|
|
|
|
|
|
voice *voice.TokenIssuer
|
2026-09-20 19:01:14 +03:00
|
|
|
|
// voiceAdmin — RoomService для модерации на стороне SFU.
|
|
|
|
|
|
voiceAdmin *voice.AdminClient
|
2026-09-20 22:10:09 +03:00
|
|
|
|
// sysinfoPaths, cpuSampler и hostCPUSampler — источники живых метрик
|
|
|
|
|
|
// дашборда инстанса: контейнер и вся машина.
|
|
|
|
|
|
sysinfoPaths sysinfo.Paths
|
|
|
|
|
|
cpuSampler *sysinfo.Sampler
|
|
|
|
|
|
hostCPUSampler *sysinfo.Sampler
|
2026-09-20 21:26:17 +03:00
|
|
|
|
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
|
|
|
|
|
metricsLimiter *httpx.RateLimiter
|
|
|
|
|
|
// health — кэш тяжёлых проверок инстанса.
|
|
|
|
|
|
health healthCache
|
2026-09-20 01:11:04 +03:00
|
|
|
|
// presence — время последнего обновления last_seen по пользователю.
|
|
|
|
|
|
presenceMu sync.Mutex
|
|
|
|
|
|
presence map[uint64]time.Time
|
2026-09-20 18:31:51 +03:00
|
|
|
|
// webhookSeen — идентификаторы обработанных вебхуков LiveKit (идемпотентность).
|
|
|
|
|
|
webhookMu sync.Mutex
|
|
|
|
|
|
webhookSeen map[string]time.Time
|
|
|
|
|
|
logger *slog.Logger
|
|
|
|
|
|
http *http.Server
|
|
|
|
|
|
static *staticHandler
|
2026-09-22 23:17:22 +03:00
|
|
|
|
// updates отдаёт манифесты автообновления desktop-обёртки (docs/client-tauri.md §4).
|
|
|
|
|
|
updates *updatesHandler
|
|
|
|
|
|
api huma.API
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
2026-09-19 18:20:56 +03:00
|
|
|
|
s := &Server{
|
2026-09-19 21:42:41 +03:00
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
db: db,
|
|
|
|
|
|
store: deps.Store,
|
|
|
|
|
|
auth: deps.Auth,
|
|
|
|
|
|
gateway: deps.Gateway,
|
|
|
|
|
|
logger: logger,
|
|
|
|
|
|
static: newStaticHandler(cfg.WebRoot),
|
2026-09-22 23:17:22 +03:00
|
|
|
|
updates: newUpdatesHandler(cfg.UpdatesDir),
|
2026-09-19 21:51:47 +03:00
|
|
|
|
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
|
|
|
|
|
|
// пользователя/IP — дефолты AGENT.md 8.6.
|
|
|
|
|
|
authLimiter: httpx.NewRateLimiter(5, 5),
|
|
|
|
|
|
apiLimiter: httpx.NewRateLimiter(120, 60),
|
2026-09-19 23:32:11 +03:00
|
|
|
|
// 5 сообщений за 5 секунд (burst 10), typing 1/3 c, поиск 10/мин.
|
2026-09-20 19:01:14 +03:00
|
|
|
|
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),
|
2026-09-21 00:26:50 +03:00
|
|
|
|
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
2026-09-22 00:40:10 +03:00
|
|
|
|
// Второй аргумент NewRateLimiter — запас (burst), а не окно.
|
|
|
|
|
|
uploadLimiter: httpx.NewRateLimiter(10, 10),
|
|
|
|
|
|
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
|
|
|
|
|
|
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
2026-09-26 15:12:43 +03:00
|
|
|
|
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
2026-09-26 15:13:24 +03:00
|
|
|
|
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
2026-09-26 16:14:20 +03:00
|
|
|
|
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
2026-09-26 16:15:00 +03:00
|
|
|
|
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
|
|
|
|
|
// ответы лимит не тратят).
|
|
|
|
|
|
unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20),
|
2026-09-26 16:14:20 +03:00
|
|
|
|
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
|
|
|
|
|
// упоминания не должны заваливать устройство.
|
|
|
|
|
|
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
|
|
|
|
|
slowmode: map[string]time.Time{},
|
|
|
|
|
|
presence: map[uint64]time.Time{},
|
|
|
|
|
|
webhookSeen: map[string]time.Time{},
|
2026-09-20 21:26:17 +03:00
|
|
|
|
// Дашборд опрашивает метрики раз в секунду: 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),
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
2026-09-20 21:26:17 +03:00
|
|
|
|
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
|
2026-09-20 22:10:09 +03:00
|
|
|
|
s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths)
|
2026-09-19 21:50:06 +03:00
|
|
|
|
switch {
|
|
|
|
|
|
case deps.Permissions != nil:
|
|
|
|
|
|
s.perms = deps.Permissions
|
|
|
|
|
|
case deps.Store != nil:
|
|
|
|
|
|
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
|
|
|
|
|
}
|
2026-09-26 16:15:00 +03:00
|
|
|
|
// Превью ссылок (Фаза 7): загрузчик создаётся всегда, а выключенный
|
|
|
|
|
|
// unfurl просто отвечает `unfurl.disabled` и в интернет не ходит.
|
|
|
|
|
|
if deps.Store != nil {
|
|
|
|
|
|
s.unfurl = newLinkPreviewer(cfg, deps.Store, logger)
|
|
|
|
|
|
}
|
2026-09-26 16:14:20 +03:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-19 21:36:00 +03:00
|
|
|
|
|
|
|
|
|
|
router := chi.NewRouter()
|
|
|
|
|
|
router.Route("/api/v1", func(apiRouter chi.Router) {
|
2026-09-19 21:50:06 +03:00
|
|
|
|
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
|
|
|
|
|
apiRouter.Use(s.sessionContext)
|
2026-09-20 21:26:17 +03:00
|
|
|
|
// Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go).
|
|
|
|
|
|
apiRouter.Use(s.apiLimiter.MiddlewareExcept(apiRateLimitKey, metricsPath))
|
2026-09-22 00:40:10 +03:00
|
|
|
|
// Строгая проверка Origin для state-changing запросов (AGENT.md 9.7).
|
|
|
|
|
|
apiRouter.Use(httpx.OriginCheck(cfg.AllowedOrigins()))
|
2026-09-19 21:36:00 +03:00
|
|
|
|
s.api = s.registerAPI(apiRouter)
|
|
|
|
|
|
s.registerMetaRoutes(s.api)
|
|
|
|
|
|
s.registerAuthRoutes(apiRouter)
|
2026-09-26 15:12:43 +03:00
|
|
|
|
if deps.Auth != nil {
|
|
|
|
|
|
s.registerPasskeyRoutes(apiRouter)
|
2026-09-26 15:13:24 +03:00
|
|
|
|
s.registerOAuthRoutes(apiRouter)
|
2026-09-26 15:12:43 +03:00
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
|
if deps.Store != nil {
|
|
|
|
|
|
s.registerUserRoutes(s.api)
|
|
|
|
|
|
s.registerGuildRoutes(s.api)
|
|
|
|
|
|
s.registerInstanceRoutes(s.api)
|
2026-09-20 22:38:48 +03:00
|
|
|
|
s.registerBanRoutes(s.api)
|
2026-09-20 21:26:17 +03:00
|
|
|
|
s.registerMetricsRoutes(s.api)
|
2026-09-19 23:32:11 +03:00
|
|
|
|
s.registerMessageRoutes(s.api)
|
2026-09-19 23:33:34 +03:00
|
|
|
|
s.registerInviteRoutes(s.api)
|
2026-09-19 23:36:16 +03:00
|
|
|
|
s.registerFileRoutes(s.api, apiRouter)
|
2026-09-20 23:26:46 +03:00
|
|
|
|
s.registerGuildAppearanceRoutes(apiRouter)
|
2026-09-20 01:11:04 +03:00
|
|
|
|
s.registerSocialRoutes(s.api)
|
2026-09-20 18:01:54 +03:00
|
|
|
|
s.registerEmojiRoutes(s.api, apiRouter)
|
2026-09-20 18:04:59 +03:00
|
|
|
|
s.registerVoiceRoutes(s.api)
|
2026-09-20 19:01:14 +03:00
|
|
|
|
s.registerSoundsRoutes(s.api, apiRouter)
|
2026-09-21 00:26:50 +03:00
|
|
|
|
s.registerWebhookRoutes(s.api, apiRouter)
|
2026-09-21 01:28:48 +03:00
|
|
|
|
s.registerCosmeticRoutes(s.api, apiRouter)
|
2026-09-21 18:08:21 +03:00
|
|
|
|
s.registerChannelBackgroundRoutes(apiRouter)
|
2026-09-21 19:25:06 +03:00
|
|
|
|
s.registerInviteBackgroundRoutes(apiRouter)
|
2026-09-20 18:31:51 +03:00
|
|
|
|
s.registerVoiceWebhook(apiRouter)
|
2026-09-26 16:14:20 +03:00
|
|
|
|
s.registerPushRoutes(s.api)
|
2026-09-26 16:15:00 +03:00
|
|
|
|
s.registerUnfurlRoutes(s.api)
|
2026-09-19 21:50:06 +03:00
|
|
|
|
}
|
2026-09-19 21:36:00 +03:00
|
|
|
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
|
|
|
|
|
})
|
|
|
|
|
|
s.registerRoutes(router)
|
|
|
|
|
|
|
|
|
|
|
|
handler := httpx.Chain(router,
|
2026-09-22 00:40:10 +03:00
|
|
|
|
httpx.SecurityHeaders(httpx.SecurityOptions{
|
|
|
|
|
|
FilesDomain: cfg.FilesDomain,
|
|
|
|
|
|
LiveKitURL: cfg.LiveKitURL,
|
|
|
|
|
|
}),
|
2026-09-19 18:20:56 +03:00
|
|
|
|
httpx.RequestID,
|
2026-09-19 21:36:00 +03:00
|
|
|
|
httpx.RequestInfoMiddleware,
|
2026-09-19 18:20:56 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
// 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)
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
func (s *Server) registerRoutes(router chi.Router) {
|
|
|
|
|
|
router.Get("/healthz", s.handleHealthz)
|
|
|
|
|
|
router.Get("/readyz", s.handleReadyz)
|
2026-09-19 21:42:41 +03:00
|
|
|
|
if s.gateway != nil {
|
|
|
|
|
|
router.Handle("/gateway", s.gateway.Handler())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
router.HandleFunc("/gateway", func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
|
writeAPIError(w, auth.ErrSessionExpired)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-09-19 21:36:00 +03:00
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
|
if s.store != nil {
|
|
|
|
|
|
s.registerFileDownload(router)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:17:22 +03:00
|
|
|
|
// Манифест автообновления 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) })
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
router.NotFound(s.handleFallback)
|
|
|
|
|
|
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
|
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
|
|
|
|
|
})
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) }
|
|
|
|
|
|
|
2026-09-20 18:31:51 +03:00
|
|
|
|
// StartVoiceWatchdog запускает периодическую очистку голосовых состояний.
|
|
|
|
|
|
func (s *Server) StartVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
|
|
|
|
|
s.startVoiceWatchdog(ctx, interval)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 18:20:56 +03:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:51:47 +03:00
|
|
|
|
// apiRateLimitKey ключует лимит по пользователю, а для анонимных запросов — по IP.
|
|
|
|
|
|
func apiRateLimitKey(r *http.Request) string {
|
|
|
|
|
|
if token := sessionToken(r); token != "" {
|
|
|
|
|
|
return "session:" + crypto.HashToken(token)
|
|
|
|
|
|
}
|
|
|
|
|
|
return "ip:" + httpx.ClientIP(r, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
|
func normalizeBearer(value string) string {
|
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
|
|
|
|
|
return strings.TrimSpace(value[7:])
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
2026-09-19 21:36:00 +03:00
|
|
|
|
return strings.TrimSpace(value)
|
2026-09-19 18:20:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var startedAt = time.Now()
|