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 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"
|
|
|
|
|
|
"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"
|
|
|
|
|
|
"glchat/internal/source"
|
2026-09-19 21:36:00 +03:00
|
|
|
|
"glchat/internal/store"
|
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.
|
|
|
|
|
|
perms *permissions.Calculator
|
|
|
|
|
|
logger *slog.Logger
|
|
|
|
|
|
http *http.Server
|
|
|
|
|
|
static *staticHandler
|
|
|
|
|
|
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-19 18:20:56 +03:00
|
|
|
|
}
|
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-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-19 21:36:00 +03:00
|
|
|
|
s.api = s.registerAPI(apiRouter)
|
|
|
|
|
|
s.registerMetaRoutes(s.api)
|
|
|
|
|
|
s.registerAuthRoutes(apiRouter)
|
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-19 21:36:00 +03:00
|
|
|
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
|
|
|
|
|
})
|
|
|
|
|
|
s.registerRoutes(router)
|
|
|
|
|
|
|
|
|
|
|
|
handler := httpx.Chain(router,
|
2026-09-19 18:20:56 +03:00
|
|
|
|
httpx.SecurityHeaders(cfg.FilesDomain),
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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) }
|
|
|
|
|
|
|
|
|
|
|
|
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: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()
|