Files
glchat/internal/server/api_users.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

340 lines
10 KiB
Go

package server
import (
"context"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
)
// profilePayload — публичный профиль пользователя (AGENT.md 8.2).
type profilePayload struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
Status string `json:"status"`
CustomStatus string `json:"custom_status"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
BannerFileID string `json:"banner_file_id,omitempty"`
IsInstanceAdmin bool `json:"is_instance_admin"`
Badges []string `json:"badges"`
Locale string `json:"locale"`
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
OnboardingCompleted bool `json:"onboarding_completed"`
}
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
payload := profilePayload{
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
Bio: user.Bio,
Status: user.Status,
CustomStatus: user.CustomStatus,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: user.Badges,
OnboardingCompleted: user.OnboardingCompletedAt != nil,
}
if payload.Badges == nil {
payload.Badges = []string{}
}
if user.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
}
if user.BannerFileID != nil {
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
}
if includePrivate {
payload.Locale = user.Locale
}
return payload
}
type meOutput struct {
Body struct {
User profilePayload `json:"user"`
}
}
type userOutput struct {
Body struct {
User profilePayload `json:"user"`
}
}
type okOutput struct {
Body struct {
OK bool `json:"ok"`
}
}
func newOKOutput() *okOutput {
output := &okOutput{}
output.Body.OK = true
return output
}
type updateProfileInput struct {
Body struct {
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
Bio *string `json:"bio,omitempty" maxLength:"500"`
Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"`
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
Locale *string `json:"locale,omitempty" enum:"ru,en"`
}
}
type changePasswordInput struct {
Body struct {
CurrentPassword string `json:"current_password" minLength:"1"`
NewPassword string `json:"new_password" minLength:"1"`
}
}
type onboardingInput struct {
Body struct {
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
Bio *string `json:"bio,omitempty" maxLength:"500"`
Locale *string `json:"locale,omitempty" enum:"ru,en"`
}
}
type guildSummary struct {
ID string `json:"id"`
Name string `json:"name"`
IconFileID string `json:"icon_file_id,omitempty"`
OwnerID string `json:"owner_id"`
IsMain bool `json:"is_main"`
MemberCount int `json:"member_count"`
MyRoleIDs []string `json:"my_role_ids"`
MyPermissions []string `json:"my_permissions"`
}
type guildListOutput struct {
Body struct {
Guilds []guildSummary `json:"guilds"`
}
}
// registerUserRoutes описывает ручки профиля, онбординга и списка серверов.
func (s *Server) registerUserRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getCurrentUser",
Method: http.MethodGet,
Path: "/users/@me",
Summary: "Текущий пользователь",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
output := &meOutput{}
output.Body.User = profileFromUser(user, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateCurrentUser",
Method: http.MethodPatch,
Path: "/users/@me",
Summary: "Изменить профиль",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
params := store.UpdateUserParams{
DisplayName: input.Body.DisplayName,
Bio: input.Body.Bio,
Status: input.Body.Status,
CustomStatus: input.Body.CustomStatus,
CustomStatusEmoji: input.Body.CustomStatusEmoji,
Locale: input.Body.Locale,
}
if input.Body.DisplayName != nil {
trimmed := strings.TrimSpace(*input.Body.DisplayName)
if trimmed == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty")
}
params.DisplayName = &trimmed
}
updated, err := s.store.UpdateUser(ctx, user.ID, params)
if err != nil {
return nil, humaError(err)
}
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
s.dispatchUserUpdate(updated)
output := &meOutput{}
output.Body.User = profileFromUser(updated, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "changePassword",
Method: http.MethodPost,
Path: "/users/@me/password",
Summary: "Сменить пароль (требует step-up)",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
return nil, humaError(err)
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "completeOnboarding",
Method: http.MethodPost,
Path: "/users/@me/onboarding/complete",
Summary: "Завершить первичную настройку",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *onboardingInput) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
params := store.UpdateUserParams{
Bio: input.Body.Bio,
Locale: input.Body.Locale,
}
if input.Body.DisplayName != nil {
if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" {
params.DisplayName = &trimmed
}
}
if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil {
return nil, humaError(err)
}
if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil {
return nil, humaError(err)
}
updated, err := s.store.GetUser(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
s.dispatchUserUpdate(updated)
output := &meOutput{}
output.Body.User = profileFromUser(updated, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getUser",
Method: http.MethodGet,
Path: "/users/{user_id}",
Summary: "Публичный профиль пользователя",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*userOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
id, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
user, err := s.store.GetUser(ctx, id)
if err != nil {
return nil, humaError(err)
}
output := &userOutput{}
output.Body.User = profileFromUser(user, false)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listMyGuilds",
Method: http.MethodGet,
Path: "/users/@me/guilds",
Summary: "Серверы текущего пользователя",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
summaries := make([]guildSummary, 0, len(guilds))
for _, guild := range guilds {
resolved, err := s.guildPermissions(ctx, guild.ID, user)
if err != nil {
return nil, err
}
if !resolved.Has(permissions.ViewGuild) {
continue
}
summary, err := s.guildSummary(ctx, guild, user.ID, resolved)
if err != nil {
return nil, err
}
summaries = append(summaries, summary)
}
output := &guildListOutput{}
output.Body.Guilds = summaries
return output, nil
})
}
// guildSummary собирает краткую карточку сервера для списка.
func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) {
summary := guildSummary{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
MyRoleIDs: []string{},
MyPermissions: permissions.Names(resolved.Guild),
}
if guild.IconFileID != nil {
summary.IconFileID = formatSnowflake(*guild.IconFileID)
}
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
if err != nil {
return guildSummary{}, humaError(err)
}
for _, roleID := range roleIDs {
summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID))
}
count, err := s.store.CountGuildMembers(ctx, guild.ID)
if err != nil {
return guildSummary{}, humaError(err)
}
summary.MemberCount = count
return summary, nil
}
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
func (s *Server) dispatchUserUpdate(user *store.User) {
if s.gateway == nil {
return
}
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
"user": profileFromUser(user, true),
})
}