3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
503 lines
17 KiB
Go
503 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 — событие безопасности владельца аккаунта; карточка
|
|
// пользователя в админ-панели дополняет его метаданными (AGENT.md 7.18).
|
|
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"`
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|