Files
glchat/internal/server/api_context.go
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

159 lines
5.6 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
)
// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена.
type sessionContextValue struct {
User *store.User
Session *store.Session
}
type sessionContextKey struct{}
// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст.
// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI).
func (s *Server) sessionContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
next.ServeHTTP(w, r)
return
}
token := sessionToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
user, session, err := s.auth.ResolveSession(r.Context(), token)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
func sessionToken(r *http.Request) string {
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
return normalizeBearer(r.Header.Get("Authorization"))
}
func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) {
value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue)
if !ok || value.User == nil || value.Session == nil {
return nil, nil, false
}
return value.User, value.Session, true
}
// requireUser достаёт текущего пользователя или возвращает 401 в конверте API.
func requireUser(ctx context.Context) (*store.User, *store.Session, error) {
user, session, ok := sessionFromContext(ctx)
if !ok {
return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
}
return user, session, nil
}
// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5).
func requireInstanceAdmin(ctx context.Context) (*store.User, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !user.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
return user, nil
}
// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}}
// (AGENT.md 8.5): клиент разбирает код, а не текст.
type humaAPIError struct {
status int
code string
message string
}
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
func (e *humaAPIError) GetStatus() int { return e.status }
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"error": map[string]any{"code": e.code, "message": e.message},
})
}
// humaErrorStatus создаёт ошибку с явным кодом.
func humaErrorStatus(status int, code, message string) huma.StatusError {
return &humaAPIError{status: status, code: code, message: message}
}
// humaError переводит доменную ошибку в ответ API с тем же кодом, что и
// chi-ручки (единая таблица в errors.go).
func humaError(err error) huma.StatusError {
if err == nil {
return nil
}
converted := newAPIError(err)
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
}
// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4).
func parseID(name, value string) (uint64, error) {
if value == "" {
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required")
}
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id")
}
return parsed, nil
}
// guildPermissions считает права пользователя на сервере.
func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) {
resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
}
return permissions.Resolved{}, humaError(err)
}
if !resolved.Has(permissions.ViewGuild) {
// Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7).
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
}
return resolved, nil
}
// requireGuildPermission считает права и требует конкретное разрешение.
func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) {
id, err := parseID("guild_id", guildID)
if err != nil {
return 0, permissions.Resolved{}, err
}
resolved, err := s.guildPermissions(ctx, id, user)
if err != nil {
return 0, permissions.Resolved{}, err
}
if !resolved.Has(permission) {
return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
}
return id, resolved, nil
}