package server import ( "context" "log/slog" "net/http" "strings" "time" "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"` // CustomStatusEmoji — эмодзи статуса: клиент подставляет его в редактор // профиля, поэтому поле отдаётся всегда, а не только непустым. CustomStatusEmoji string `json:"custom_status_emoji"` 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"` // TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код // при step-up и показывать ли QR при настройке (AGENT.md 7.1). TOTPEnabled bool `json:"totp_enabled"` // Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент // показывает время последнего входа друзьям. Timezone string `json:"timezone"` // LastSeenAt — когда пользователя видели последний раз (для друзей). LastSeenAt string `json:"last_seen_at,omitempty"` // CreatedAt — дата регистрации: показывается в чужом профиле (AGENT.md 7.2). CreatedAt string `json:"created_at,omitempty"` // Cosmetics — оформление «для друзей»: применяется в DM, друзьях и // глобальных местах (AGENT.md 7.2). Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } // profileFromUser собирает профиль; состояние 2FA читается из сервиса // аутентификации (секрет в БД зашифрован, наружу отдаём только факт). func (s *Server) profileFromUser(ctx context.Context, user *store.User, includePrivate bool) profilePayload { payload := profileFromUser(user, includePrivate) if s.auth != nil { if enabled, err := s.auth.TOTPEnabled(ctx, user.ID); err == nil { payload.TOTPEnabled = enabled } } return payload } 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, CustomStatusEmoji: user.CustomStatusEmoji, 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 } payload.Timezone = user.Timezone if payload.Timezone == "" { payload.Timezone = "Europe/Moscow" } if user.LastSeenAt != nil { payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339) } if !user.CreatedAt.IsZero() { payload.CreatedAt = user.CreatedAt.UTC().Format(time.RFC3339) } 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"` Timezone *string `json:"timezone,omitempty" maxLength:"64"` } } 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"` } } // securityEventPayload — событие безопасности без служебных метаданных. type securityEventPayload struct { ID string `json:"id"` Type string `json:"type"` IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` CreatedAt string `json:"created_at"` } type securityEventListOutput struct { Body struct { Events []securityEventPayload `json:"events"` } } type guildSummary struct { ID string `json:"id"` Name string `json:"name"` IconFileID string `json:"icon_file_id,omitempty"` BannerFileID string `json:"banner_file_id,omitempty"` SplashFileID string `json:"splash_file_id,omitempty"` AccentColor int64 `json:"accent_color,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 = s.profileFromUser(ctx, 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.Timezone != nil { if _, err := time.LoadLocation(*input.Body.Timezone); err != nil { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown timezone") } } 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) } if input.Body.Timezone != nil { if err := s.store.SetTimezone(ctx, user.ID, *input.Body.Timezone); err != nil { return nil, humaError(err) } updated, err = s.store.GetUser(ctx, user.ID) if err != nil { return nil, humaError(err) } } // Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3), // а друзья — обновление присутствия (AGENT.md 7.16). s.dispatchUserUpdate(updated) if input.Body.Status != nil || input.Body.CustomStatus != nil { s.dispatchPresenceUpdate(ctx, updated) } output := &meOutput{} output.Body.User = s.profileFromUser(ctx, 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) } // Смена пароля отзывает остальные сессии: их соединения закрываем сразу, // а устройство, с которого пароль сменили, продолжает работать // (AGENT.md 7.1, 11.6). if s.gateway != nil { s.gateway.InvalidateUserExcept(user.ID, session.TokenHash, "password_changed") } 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 = s.profileFromUser(ctx, updated, true) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listSecurityEvents", Method: http.MethodGet, Path: "/users/@me/security-events", Summary: "Последние события безопасности аккаунта (входы, смена пароля, 2FA)", Tags: []string{"Users"}, Security: security, }, func(ctx context.Context, input *struct { Limit int `query:"limit" default:"20" minimum:"1" maximum:"100"` }, ) (*securityEventListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } events, err := s.store.ListSecurityEvents(ctx, user.ID, input.Limit) if err != nil { return nil, humaError(err) } output := &securityEventListOutput{} output.Body.Events = make([]securityEventPayload, 0, len(events)) for _, event := range events { output.Body.Events = append(output.Body.Events, securityEventPayload{ ID: formatSnowflake(event.ID), Type: event.Type, IP: event.IP, UserAgent: event.UserAgent, CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339), }) } 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 = s.profileFromUser(ctx, user, false) // Оформление «для друзей»: в чужом профиле видно глобальный стиль (7.2). if cosmetics, err := s.store.EffectiveCosmeticsForUser(ctx, user.ID); err == nil { output.Body.User.Cosmetics = cosmeticsFromEffective(cosmetics) } 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 } // Инстанс-админ видит все серверы инстанса — тем же списком, что и // READY, иначе подстраховочный REST-запрос выкинул бы чужой сервер из // рейки (AGENT.md 7.19, 11.5). var guilds []store.Guild if user.IsInstanceAdmin { guilds, err = s.store.ListAllGuilds(ctx) } else { 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) } if guild.BannerFileID != nil { summary.BannerFileID = formatSnowflake(*guild.BannerFileID) } if guild.SplashFileID != nil { summary.SplashFileID = formatSnowflake(*guild.SplashFileID) } summary.AccentColor = guild.AccentColor 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 } // dispatchPresenceUpdate рассылает статус пользователя его друзьям. func (s *Server) dispatchPresenceUpdate(ctx context.Context, user *store.User) { if s.gateway == nil { return } friends, err := s.store.ListRelationships(ctx, user.ID, store.RelationshipFriend) if err != nil { s.logger.WarnContext(ctx, "failed to list friends for presence", slog.Any("error", err)) return } payload := map[string]any{ "user_id": formatSnowflake(user.ID), "status": user.Status, "visible_status": visibleStatus(user.Status, user.LastSeenAt), "custom_status": user.CustomStatus, } if user.LastSeenAt != nil { payload["last_seen_at"] = user.LastSeenAt.UTC().Format(timeLayout) } for _, friend := range friends { s.gateway.SendToUser(friend.UserID, "PRESENCE_UPDATE", payload) } } // dispatchUserUpdate рассылает обновление профиля во все сессии пользователя. func (s *Server) dispatchUserUpdate(user *store.User) { if s.gateway == nil { return } s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{ "user": s.profileFromUser(context.Background(), user, true), }) }