1b1a679827
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).
340 lines
10 KiB
Go
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),
|
|
})
|
|
}
|