Files
glchat/internal/server/api_users.go
T
grendervill 48e30a1897 feat(cosmetics): оформление профиля — галерея, роли и личные стили
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
  и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
  (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
  больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
  GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
  настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
  ника (CSS, при reduced-motion статично) в ленте и списке участников
2026-09-21 01:28:48 +03:00

482 lines
16 KiB
Go

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"`
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,
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)
}
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
}
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),
})
}