package server import ( "context" "crypto/rand" "encoding/csv" "encoding/json" "errors" "log/slog" "net/http" "strconv" "strings" "time" "github.com/danielgtaylor/huma/v2" "github.com/go-chi/chi/v5" "glchat/internal/bootstrap" "glchat/internal/store" "glchat/internal/sysinfo" ) // instancePayload — публичная информация об инстансе (AGENT.md 6.5). type instancePayload struct { Name string `json:"name"` Version string `json:"version"` RegistrationEnabled bool `json:"registration_enabled"` AllowGuildCreation bool `json:"allow_guild_creation"` VoiceEnabled bool `json:"voice_enabled"` MaxGuildsPerUser int `json:"max_guilds_per_user"` MaxMembersPerGuild int `json:"max_members_per_guild"` MaxMessageLength int `json:"max_message_length"` // AdminGuildID — сервер, созданный установщиком (справочно, автовхода нет). AdminGuildID string `json:"main_guild_id,omitempty"` UserCount int `json:"user_count"` GuildCount int `json:"guild_count"` } type instanceOutput struct { Body struct { Instance instancePayload `json:"instance"` } } type instanceGuildPayload struct { ID string `json:"id"` Name string `json:"name"` OwnerID string `json:"owner_id"` OwnerName string `json:"owner_name,omitempty"` IsMain bool `json:"is_main"` MemberCount int `json:"member_count"` CreatedAt string `json:"created_at"` } type instanceGuildListOutput struct { Body struct { Guilds []instanceGuildPayload `json:"guilds"` // Total — сколько серверов попадает в фильтр: панель листает страницы // (AGENT.md 7.18). Total int `json:"total"` } } type instanceUserPayload struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` IsInstanceAdmin bool `json:"is_instance_admin"` Badges []string `json:"badges"` CreatedAt string `json:"created_at"` // Глобальный бан инстанса (AGENT.md 7.18). Banned bool `json:"banned"` BannedAt string `json:"banned_at,omitempty"` BanReason string `json:"ban_reason,omitempty"` } type instanceUserListOutput struct { Body struct { Users []instanceUserPayload `json:"users"` Total int `json:"total"` } } // instanceUserPayloadFrom собирает ответ панели по модели пользователя. func instanceUserPayloadFrom(user store.User) instanceUserPayload { badges := user.Badges if badges == nil { badges = []string{} } payload := instanceUserPayload{ ID: formatSnowflake(user.ID), Username: user.Username, DisplayName: user.DisplayName, IsInstanceAdmin: user.IsInstanceAdmin, Badges: badges, CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339), Banned: user.BannedAt != nil, BanReason: user.BanReason, } if user.BannedAt != nil { payload.BannedAt = user.BannedAt.UTC().Format(time.RFC3339) } return payload } type instanceSettingsPayload struct { RegistrationEnabled bool `json:"registration_enabled"` AllowGuildCreation bool `json:"allow_guild_creation"` MaxGuildsPerUser int `json:"max_guilds_per_user"` MaxMembersPerGuild int `json:"max_members_per_guild"` MaxMessageLength int `json:"max_message_length"` // Лимиты медиа в байтах (AGENT.md 7.7): действуют на все сервера. MaxAvatarSize int64 `json:"max_avatar_size"` MaxGuildImageSize int64 `json:"max_guild_image_size"` MaxEmojiSize int64 `json:"max_emoji_size"` MaxSoundSize int64 `json:"max_sound_size"` MaxCosmeticSize int64 `json:"max_cosmetic_size"` } // instanceSettingsFromStore собирает ответ настроек в одном месте. func instanceSettingsFromStore(settings *store.InstanceSettings) instanceSettingsPayload { return instanceSettingsPayload{ RegistrationEnabled: settings.RegistrationEnabled, AllowGuildCreation: settings.AllowGuildCreation, MaxGuildsPerUser: settings.MaxGuildsPerUser, MaxMembersPerGuild: settings.MaxMembersPerGuild, MaxMessageLength: settings.MaxMessageLength, MaxAvatarSize: settings.MaxAvatarSize, MaxGuildImageSize: settings.MaxGuildImageSize, MaxEmojiSize: settings.MaxEmojiSize, MaxSoundSize: settings.MaxSoundSize, MaxCosmeticSize: settings.MaxCosmeticSize, } } type adminPasswordOutput struct { Body struct { UserID string `json:"user_id"` Password string `json:"password"` SessionsRevoked int64 `json:"sessions_revoked"` } } type instanceSettingsOutput struct { Body struct { Settings instanceSettingsPayload `json:"settings"` } } // registerInstanceRoutes описывает публичную информацию об инстансе и // админ-панель администратора инстанса (AGENT.md 6.5, 7.19). func (s *Server) registerInstanceRoutes(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "getInstance", Method: http.MethodGet, Path: "/instance", Summary: "Публичная информация об инстансе", Tags: []string{"Instance"}, }, func(ctx context.Context, _ *struct{}) (*instanceOutput, error) { payload, err := s.instancePayload(ctx) if err != nil { return nil, err } // Лимиты и число пользователей видны только администратору инстанса. if user, _, ok := sessionFromContext(ctx); !ok || !user.IsInstanceAdmin { payload.MaxGuildsPerUser = 0 payload.MaxMembersPerGuild = 0 payload.UserCount = 0 payload.GuildCount = 0 } output := &instanceOutput{} output.Body.Instance = payload return output, nil }) huma.Register(api, huma.Operation{ OperationID: "getInstanceSettings", Method: http.MethodGet, Path: "/instance/settings", Summary: "Настройки инстанса (только администратор)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, _ *struct{}) (*instanceSettingsOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } settings, err := s.store.InstanceSettings(ctx) if err != nil { return nil, humaError(err) } output := &instanceSettingsOutput{} output.Body.Settings = instanceSettingsFromStore(settings) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "updateInstanceSettings", Method: http.MethodPatch, Path: "/instance/settings", Summary: "Изменить настройки инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { Body struct { RegistrationEnabled *bool `json:"registration_enabled,omitempty"` AllowGuildCreation *bool `json:"allow_guild_creation,omitempty"` MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"` MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"` MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"` // Лимиты медиа: 64 КБ — 512 МБ (AGENT.md 7.7). MaxAvatarSize *int64 `json:"max_avatar_size,omitempty" minimum:"65536" maximum:"536870912"` MaxGuildImageSize *int64 `json:"max_guild_image_size,omitempty" minimum:"65536" maximum:"536870912"` MaxEmojiSize *int64 `json:"max_emoji_size,omitempty" minimum:"65536" maximum:"536870912"` MaxSoundSize *int64 `json:"max_sound_size,omitempty" minimum:"65536" maximum:"536870912"` MaxCosmeticSize *int64 `json:"max_cosmetic_size,omitempty" minimum:"65536" maximum:"536870912"` } }, ) (*instanceSettingsOutput, error) { user, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } updates := map[string]string{} if input.Body.RegistrationEnabled != nil { updates["registration_enabled"] = strconv.FormatBool(*input.Body.RegistrationEnabled) } if input.Body.AllowGuildCreation != nil { updates["allow_guild_creation"] = strconv.FormatBool(*input.Body.AllowGuildCreation) } if input.Body.MaxGuildsPerUser != nil { updates["max_guilds_per_user"] = strconv.Itoa(*input.Body.MaxGuildsPerUser) } if input.Body.MaxMembersPerGuild != nil { updates["max_members_per_guild"] = strconv.Itoa(*input.Body.MaxMembersPerGuild) } if input.Body.MaxMessageLength != nil { updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength) } for key, value := range map[string]*int64{ "max_avatar_size": input.Body.MaxAvatarSize, "max_guild_image_size": input.Body.MaxGuildImageSize, "max_emoji_size": input.Body.MaxEmojiSize, "max_sound_size": input.Body.MaxSoundSize, "max_cosmetic_size": input.Body.MaxCosmeticSize, } { if value != nil { updates[key] = strconv.FormatInt(*value, 10) } } for key, value := range updates { if err := s.store.SetInstanceSetting(ctx, key, value); err != nil { return nil, humaError(err) } } settings, err := s.store.InstanceSettings(ctx) if err != nil { return nil, humaError(err) } s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "") output := &instanceSettingsOutput{} output.Body.Settings = instanceSettingsFromStore(settings) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listInstanceGuilds", Method: http.MethodGet, Path: "/instance/guilds", Summary: "Все серверы инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { // Фильтры списка серверов (AGENT.md 7.18): поиск по названию, владелец, // только главный сервер, плюс страница выдачи. Query string `query:"q" maxLength:"64"` OwnerID string `query:"owner_id"` Main bool `query:"main"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` Offset int `query:"offset" default:"0" minimum:"0"` }, ) (*instanceGuildListOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } var ownerID uint64 if input.OwnerID != "" { parsed, err := parseID("owner_id", input.OwnerID) if err != nil { return nil, err } ownerID = parsed } rows, total, err := s.store.ListInstanceGuilds(ctx, store.InstanceGuildFilter{ Query: input.Query, OwnerID: ownerID, MainOnly: input.Main, Limit: input.Limit, Offset: input.Offset, }) if err != nil { return nil, humaError(err) } output := &instanceGuildListOutput{} output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows)) for _, row := range rows { output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{ ID: formatSnowflake(row.ID), Name: row.Name, OwnerID: formatSnowflake(row.OwnerID), OwnerName: row.OwnerName, IsMain: row.IsMain, MemberCount: row.MemberCount, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), }) } output.Body.Total = total return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminCreateGuild", Method: http.MethodPost, Path: "/instance/guilds", Summary: "Создать сервер от имени администратора (лимиты обходятся)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { Body struct { Name string `json:"name" minLength:"1" maxLength:"64"` OwnerID string `json:"owner_id,omitempty"` } }, ) (*instanceGuildListOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } owner := admin if input.Body.OwnerID != "" { ownerID, err := parseID("owner_id", input.Body.OwnerID) if err != nil { return nil, err } owner, err = s.store.GetUser(ctx, ownerID) if err != nil { return nil, humaError(err) } } name := strings.TrimSpace(input.Body.Name) if name == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") } // Администратор инстанса создаёт сервер в обход лимитов: причина // фиксируется в аудите отдельной записью (AGENT.md 6.5). guild, err := s.createGuildAsAdmin(ctx, admin, owner, name) if err != nil { return nil, err } output := &instanceGuildListOutput{} output.Body.Guilds = []instanceGuildPayload{{ ID: formatSnowflake(guild.ID), Name: guild.Name, OwnerID: formatSnowflake(guild.OwnerID), IsMain: guild.IsMain, CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339), }} return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminDeleteGuild", Method: http.MethodDelete, Path: "/instance/guilds/{guild_id}", Summary: "Удалить сервер (администратор инстанса)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` }, ) (*okOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } guildID, err := parseID("guild_id", input.GuildID) if err != nil { return nil, err } if err := s.deleteGuild(ctx, admin, guildID); err != nil { return nil, err } return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "listInstanceUsers", Method: http.MethodGet, Path: "/instance/users", Summary: "Пользователи инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { Query string `query:"q" maxLength:"64"` // Banned — показать только забаненных (AGENT.md 7.18). Banned bool `query:"banned"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` Offset int `query:"offset" default:"0" minimum:"0"` }, ) (*instanceUserListOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } users, err := s.store.ListUsers(ctx, input.Query, input.Banned, input.Limit, input.Offset) if err != nil { return nil, humaError(err) } total, err := s.store.CountUsersFiltered(ctx, input.Query, input.Banned) if err != nil { return nil, humaError(err) } output := &instanceUserListOutput{} output.Body.Users = make([]instanceUserPayload, 0, len(users)) for _, user := range users { output.Body.Users = append(output.Body.Users, instanceUserPayloadFrom(user)) } output.Body.Total = total return output, nil }) huma.Register(api, huma.Operation{ OperationID: "setInstanceAdmin", Method: http.MethodPost, Path: "/instance/users/{user_id}/admin", Summary: "Выдать или снять права администратора инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` Body struct { Admin bool `json:"admin"` // StepUpPassword подтверждает действие: смена администраторов — // чувствительная операция (AGENT.md 7.1). StepUpPassword string `json:"step_up_password,omitempty"` StepUpTOTP string `json:"step_up_totp,omitempty"` } }, ) (*userOutput, error) { admin, session, err := requireUser(ctx) if err != nil { return nil, err } if !admin.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") } if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { return nil, humaError(err) } userID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if userID == admin.ID && !input.Body.Admin { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot revoke your own administrator rights") } if err := s.store.SetInstanceAdmin(ctx, userID, input.Body.Admin); err != nil { return nil, humaError(err) } updated, err := s.store.GetUser(ctx, userID) if err != nil { return nil, humaError(err) } action := "instance.admin_grant" if !input.Body.Admin { action = "instance.admin_revoke" } s.recordAudit(ctx, admin, 0, action, "user", &userID, "") s.dispatchUserUpdate(updated) output := &userOutput{} output.Body.User = s.profileFromUser(ctx, updated, false) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminResetUserPassword", Method: http.MethodPost, Path: "/instance/users/{user_id}/reset-password", Summary: "Сбросить пароль пользователя (администратор)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*adminPasswordOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } target, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } // Пароль показывается один раз: администратор передаёт его владельцу. password, err := newTemporaryPassword() if err != nil { return nil, humaError(err) } // Считаем сессии до смены пароля: SetPassword их уже отзывает. revoked, err := s.store.RevokeUserSessions(ctx, target.ID) if err != nil { return nil, humaError(err) } if err := s.auth.SetPassword(ctx, target.ID, password); err != nil { return nil, humaError(err) } if s.gateway != nil { s.gateway.InvalidateUser(target.ID, "password_reset") } s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "") output := &adminPasswordOutput{} output.Body.UserID = formatSnowflake(target.ID) output.Body.Password = password output.Body.SessionsRevoked = revoked return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminLogoutUser", Method: http.MethodPost, Path: "/instance/users/{user_id}/logout", Summary: "Выйти со всех устройств пользователя", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*okOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil { return nil, humaError(err) } if s.gateway != nil { s.gateway.InvalidateUser(targetID, "logout_all") } s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "") return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "adminBanUser", Method: http.MethodPost, Path: "/instance/users/{user_id}/ban", Summary: "Забанить пользователя на инстансе", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` Body struct { Reason string `json:"reason,omitempty" maxLength:"400"` // Бан — действие инстанс-админа: нужна свежая проверка (AGENT.md 7.1). StepUpPassword string `json:"step_up_password,omitempty"` StepUpTOTP string `json:"step_up_totp,omitempty"` } }, ) (*userOutput, error) { admin, session, err := requireUser(ctx) if err != nil { return nil, err } if !admin.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if targetID == admin.ID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot ban yourself") } target, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } // Администратора инстанса забанить нельзя (AGENT.md 7.19). if target.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be banned") } if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { return nil, humaError(err) } reason := strings.TrimSpace(input.Body.Reason) if err := s.store.BanInstanceUser(ctx, target.ID, admin.ID, reason); err != nil { return nil, humaError(err) } if _, err := s.store.RevokeUserSessions(ctx, target.ID); err != nil { return nil, humaError(err) } if s.gateway != nil { s.gateway.InvalidateUser(target.ID, "user_banned") } s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason) updated, err := s.store.GetUser(ctx, target.ID) if err != nil { return nil, humaError(err) } output := &userOutput{} output.Body.User = s.profileFromUser(ctx, updated, false) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminUnbanUser", Method: http.MethodPost, Path: "/instance/users/{user_id}/unban", Summary: "Снять бан инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` Body struct { StepUpPassword string `json:"step_up_password,omitempty"` StepUpTOTP string `json:"step_up_totp,omitempty"` } }, ) (*userOutput, error) { admin, session, err := requireUser(ctx) if err != nil { return nil, err } if !admin.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { return nil, humaError(err) } if _, err := s.store.UnbanInstanceUser(ctx, targetID); err != nil { return nil, humaError(err) } s.recordAudit(ctx, admin, 0, "instance.user_unban", "user", &targetID, "") updated, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } output := &userOutput{} output.Body.User = s.profileFromUser(ctx, updated, false) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "adminDeleteUser", Method: http.MethodDelete, Path: "/instance/users/{user_id}", Summary: "Удалить пользователя (администратор)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*okOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if targetID == admin.ID { // Иначе администратор может удалить себя и потерять доступ. return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot delete yourself") } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } // Мягкое удаление: сообщения и аудит остаются (AGENT.md 6.4). if err := s.store.SoftDeleteUser(ctx, targetID); err != nil { return nil, humaError(err) } if s.gateway != nil { s.gateway.InvalidateUser(targetID, "user_deleted") } s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "") return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "adminUpdateGuild", Method: http.MethodPatch, Path: "/instance/guilds/{guild_id}", Summary: "Переименовать сервер (администратор инстанса)", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` Body struct { Name *string `json:"name,omitempty" maxLength:"64"` Description *string `json:"description,omitempty" maxLength:"400"` } }, ) (*okOutput, error) { admin, err := requireInstanceAdmin(ctx) if err != nil { return nil, err } guildID, err := parseID("guild_id", input.GuildID) if err != nil { return nil, err } guild, err := s.store.GetGuild(ctx, guildID) if err != nil { return nil, humaError(err) } if input.Body.Name != nil { name := strings.TrimSpace(*input.Body.Name) if name == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") } guild.Name = name } if input.Body.Description != nil { guild.Description = strings.TrimSpace(*input.Body.Description) } updated, err := s.store.UpdateGuild(ctx, guildID, store.UpdateGuildParams{ Name: &guild.Name, Description: &guild.Description, }) if err != nil { return nil, humaError(err) } s.invalidateGuild(guildID) s.dispatchGuildUpdate(*updated) s.recordAudit(ctx, admin, guildID, "instance.guild_update", "guild", &guildID, "") return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "listInstanceAudit", Method: http.MethodGet, Path: "/instance/audit", Summary: "Журнал действий администраторов инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { // Фильтры журнала (AGENT.md 7.10, 7.18): действие, актор, цель, сервер // и период. Даты — в формате YYYY-MM-DD (UTC). Action string `query:"action" maxLength:"64"` ActorID string `query:"actor_id"` TargetID string `query:"target_id"` GuildID string `query:"guild_id"` Since string `query:"since" maxLength:"10"` Until string `query:"until" maxLength:"10"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` Offset int `query:"offset" default:"0" minimum:"0"` }, ) (*instanceAuditOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } filter, err := auditFilterFromQuery(auditQueryParams{ Action: input.Action, ActorID: input.ActorID, TargetID: input.TargetID, GuildID: input.GuildID, Since: input.Since, Until: input.Until, Limit: input.Limit, Offset: input.Offset, }) if err != nil { return nil, err } entries, total, err := s.store.ListInstanceAuditFiltered(ctx, filter) if err != nil { return nil, humaError(err) } output := &instanceAuditOutput{} output.Body.Entries = auditPayloads(entries) output.Body.Total = total return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listInstanceAuditActions", Method: http.MethodGet, Path: "/instance/audit/actions", Summary: "Действия, встречающиеся в журнале инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, _ *struct{}) (*instanceAuditActionsOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } actions, err := s.store.ListInstanceAuditActions(ctx) if err != nil { return nil, humaError(err) } output := &instanceAuditActionsOutput{} output.Body.Actions = actions return output, nil }) huma.Register(api, huma.Operation{ OperationID: "getInstanceStats", Method: http.MethodGet, Path: "/instance/stats", Summary: "Статистика инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, _ *struct{}) (*instanceStatsOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } stats, err := s.store.InstanceStats(ctx) if err != nil { return nil, humaError(err) } output := &instanceStatsOutput{} output.Body.Stats = s.instanceStatsPayload(stats) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "getInstanceUser", Method: http.MethodGet, Path: "/instance/users/{user_id}", Summary: "Карточка пользователя для админ-панели", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*instanceUserCardOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } target, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } card, err := s.instanceUserCard(ctx, target) if err != nil { return nil, err } return card, nil }) huma.Register(api, huma.Operation{ OperationID: "adminRevokeUserSession", Method: http.MethodDelete, Path: "/instance/users/{user_id}/sessions/{session_id}", Summary: "Отозвать одно устройство пользователя", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` SessionID string `path:"session_id"` Body struct { // StepUpPassword подтверждает действие: отзыв устройства — // чувствительная операция (AGENT.md 7.1, 7.18). StepUpPassword string `json:"step_up_password,omitempty"` StepUpTOTP string `json:"step_up_totp,omitempty"` } }, ) (*okOutput, error) { admin, session, err := requireUser(ctx) if err != nil { return nil, err } if !admin.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") } if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { return nil, humaError(err) } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } sessionID, err := parseID("session_id", input.SessionID) if err != nil { return nil, err } targetSession, err := s.store.UserSession(ctx, targetID, sessionID) if err != nil { return nil, humaError(err) } deleted, err := s.store.DeleteUserSession(ctx, targetID, sessionID) if err != nil { return nil, humaError(err) } if !deleted { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "session not found") } // Закрываем только это соединение: остальные устройства пользователя // продолжают работу (AGENT.md 11.6). if s.gateway != nil { s.gateway.InvalidateSession(targetID, targetSession.TokenHash, "session_revoked") } s.recordSecurityEvent(ctx, &targetID, "session_revoked", session, map[string]any{ "by": "instance_admin", "session_id": formatSnowflake(sessionID), }) s.recordAudit(ctx, admin, 0, "instance.user_session_revoke", "user", &targetID, "") return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "adminResetUser2FA", Method: http.MethodPost, Path: "/instance/users/{user_id}/reset-2fa", Summary: "Сбросить второй фактор пользователя", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` Body struct { // Сброс второго фактора — восстановление доступа: без step-up // администратора это был бы готовый способ захвата аккаунта. StepUpPassword string `json:"step_up_password,omitempty"` StepUpTOTP string `json:"step_up_totp,omitempty"` } }, ) (*adminReset2FAOutput, error) { admin, session, err := requireUser(ctx) if err != nil { return nil, err } if !admin.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") } if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { return nil, humaError(err) } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if targetID == admin.ID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "use the security settings to reset your own second factor") } target, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } // У администратора инстанса второй фактор обязателен (AGENT.md 7.19): // сброс чужого ключа стал бы обходом этого правила. if target.IsInstanceAdmin { return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator second factor cannot be reset") } secret, err := s.store.GetTOTPSecret(ctx, targetID) if errors.Is(err, store.ErrNotFound) || (err == nil && !secret.Enabled) { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "two-factor authentication is not enabled") } if err != nil { return nil, humaError(err) } if err := s.store.DeleteTOTPSecret(ctx, targetID); err != nil { return nil, humaError(err) } // После сброса второго фактора сессии отзываются: владелец входит // заново паролем и настраивает новый ключ. revoked, err := s.store.RevokeUserSessions(ctx, targetID) if err != nil { return nil, humaError(err) } if s.gateway != nil { s.gateway.InvalidateUser(targetID, "2fa_reset") } s.recordSecurityEvent(ctx, &targetID, "2fa_change", session, map[string]any{ "action": "reset", "by": "instance_admin", }) s.recordAudit(ctx, admin, 0, "instance.user_2fa_reset", "user", &targetID, "") output := &adminReset2FAOutput{} output.Body.UserID = formatSnowflake(targetID) output.Body.SessionsRevoked = revoked return output, nil }) } // instancePayload собирает публичные сведения об инстансе. func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) { settings, err := s.store.InstanceSettings(ctx) if err != nil { return instancePayload{}, humaError(err) } users, err := s.store.CountUsers(ctx) if err != nil { return instancePayload{}, humaError(err) } guilds, err := s.store.ListAllGuilds(ctx) if err != nil { return instancePayload{}, humaError(err) } payload := instancePayload{ Name: s.cfg.InstanceName, Version: s.cfg.Version, RegistrationEnabled: settings.RegistrationEnabled, AllowGuildCreation: settings.AllowGuildCreation, // Признак «голос настроен»: клиент берёт его из /meta, но контракт // публичной информации об инстансе (AGENT.md 6.5) обещает и здесь. VoiceEnabled: s.cfg.VoiceEnabled(), MaxGuildsPerUser: settings.MaxGuildsPerUser, MaxMembersPerGuild: settings.MaxMembersPerGuild, MaxMessageLength: settings.MaxMessageLength, UserCount: users, GuildCount: len(guilds), } if settings.MainGuildID != 0 { payload.AdminGuildID = formatSnowflake(settings.MainGuildID) } return payload, nil } // createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите. func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) { guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{ Name: name, OwnerID: owner.ID, IsMain: false, IsDiscoverable: false, }) if err != nil { return nil, humaError(err) } if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil { return nil, humaError(err) } if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, }); err != nil { return nil, humaError(err) } s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin") s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits") if s.gateway != nil { s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)}) } return guild, nil } // temporaryPasswordAlphabet — символы временного пароля: без похожих друг на // друга, чтобы его можно было продиктовать. const temporaryPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // newTemporaryPassword генерирует пароль для выдачи пользователю: 16 символов // из криптографического источника (AGENT.md 9.2). func newTemporaryPassword() (string, error) { const length = 16 buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return "", err } var builder strings.Builder builder.Grow(length) for _, value := range buf { builder.WriteByte(temporaryPasswordAlphabet[int(value)%len(temporaryPasswordAlphabet)]) } return builder.String(), nil } // instanceAuditOutput — страница журнала инстанса с общим числом записей под // фильтр: панели нужна пагинация (AGENT.md 7.18). type instanceAuditOutput struct { Body struct { Entries []auditEntryPayload `json:"entries"` Total int `json:"total"` } } type instanceAuditActionsOutput struct { Body struct { Actions []string `json:"actions"` } } // dailyCountPayload — точка роста для графика статистики. type dailyCountPayload struct { Date string `json:"date"` Count int `json:"count"` } type instanceGuildStatPayload struct { ID string `json:"id"` Name string `json:"name"` Members int `json:"members"` Messages int `json:"messages"` } type instanceStatsPayload struct { Users struct { Total int `json:"total"` Admins int `json:"admins"` Banned int `json:"banned"` New7Days int `json:"new_7_days"` New30Days int `json:"new_30_days"` Active24h int `json:"active_24h"` Daily []dailyCountPayload `json:"daily"` } `json:"users"` Messages struct { Total int `json:"total"` Today int `json:"today"` Week int `json:"week"` Month int `json:"month"` Daily []dailyCountPayload `json:"daily"` } `json:"messages"` Files struct { Count int `json:"count"` Bytes int64 `json:"bytes"` } `json:"files"` Guilds int `json:"guilds"` Invites int `json:"invites"` Bans int `json:"bans"` DatabaseBytes int64 `json:"database_bytes"` StorageBytes int64 `json:"storage_bytes"` TopGuilds []instanceGuildStatPayload `json:"top_guilds"` BusiestGuilds []instanceGuildStatPayload `json:"busiest_guilds"` } type instanceStatsOutput struct { Body struct { Stats instanceStatsPayload `json:"stats"` } } type instanceSessionPayload struct { ID string `json:"id"` UserAgent string `json:"user_agent,omitempty"` IP string `json:"ip,omitempty"` CreatedAt string `json:"created_at"` LastSeen string `json:"last_seen"` ExpiresAt string `json:"expires_at"` // SteppedUp — сессия недавно подтверждала пароль: видно, какие устройства // администратор может отозвать без последствий для остальных. SteppedUp bool `json:"stepped_up"` } type instanceMembershipRolePayload struct { ID string `json:"id"` Name string `json:"name"` Color int64 `json:"color"` } type instanceMembershipPayload struct { GuildID string `json:"guild_id"` GuildName string `json:"guild_name"` Nickname string `json:"nickname,omitempty"` JoinedAt string `json:"joined_at"` Roles []instanceMembershipRolePayload `json:"roles"` } // instanceUserCardOutput — карточка пользователя: профиль, устройства, // серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18). type instanceUserCardOutput struct { Body struct { User instanceUserPayload `json:"user"` TOTPEnabled bool `json:"totp_enabled"` Passkeys int `json:"passkeys"` Sessions []instanceSessionPayload `json:"sessions"` Guilds []instanceMembershipPayload `json:"guilds"` SecurityEvents []securityEventPayload `json:"security_events"` Audit []auditEntryPayload `json:"audit"` AuditTotal int `json:"audit_total"` } } type adminReset2FAOutput struct { Body struct { UserID string `json:"user_id"` SessionsRevoked int64 `json:"sessions_revoked"` } } // securityEventsLimit и userAuditLimit — сколько событий и записей аудита // показывать в карточке пользователя: остальное доступно в разделе «Аудит». const ( securityEventsLimit = 20 userAuditLimit = 20 ) // auditQueryParams — фильтры журнала, как они приходят из строки запроса. // Один разбор используется и JSON-ручкой (huma), и экспортом CSV. type auditQueryParams struct { Action string ActorID string TargetID string GuildID string Since string Until string Limit int Offset int } // auditFilterFromQuery собирает фильтр журнала и разбирает даты (AGENT.md 7.10). func auditFilterFromQuery(params auditQueryParams) (store.InstanceAuditFilter, error) { filter := store.InstanceAuditFilter{ Action: strings.TrimSpace(params.Action), Limit: params.Limit, Offset: params.Offset, } for _, item := range []struct { raw string name string into *uint64 }{ {params.ActorID, "actor_id", &filter.ActorID}, {params.TargetID, "target_id", &filter.TargetID}, {params.GuildID, "guild_id", &filter.GuildID}, } { if strings.TrimSpace(item.raw) == "" { continue } value, err := parseID(item.name, strings.TrimSpace(item.raw)) if err != nil { return store.InstanceAuditFilter{}, err } *item.into = value } since, err := parseAuditDate(params.Since, false) if err != nil { return store.InstanceAuditFilter{}, err } until, err := parseAuditDate(params.Until, true) if err != nil { return store.InstanceAuditFilter{}, err } filter.Since = since filter.Until = until return filter, nil } // parseAuditDate читает дату фильтра в формате YYYY-MM-DD (UTC). Для «по дату» // берётся конец дня: иначе запись за 20 сентября не попала бы в выборку // «по 20 сентября» (время в базе хранится с точностью до миллисекунд). func parseAuditDate(value string, endOfDay bool) (*time.Time, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil, nil } parsed, err := time.Parse("2006-01-02", trimmed) if err != nil { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "date must be YYYY-MM-DD") } if endOfDay { parsed = parsed.Add(24*time.Hour - time.Millisecond) } return &parsed, nil } // instanceStatsPayload дополняет выборку из store размерами хранилища: размер // базы и файлов виден только серверу (AGENT.md 7.18). func (s *Server) instanceStatsPayload(stats *store.InstanceStats) instanceStatsPayload { payload := instanceStatsPayload{ Guilds: stats.Guilds, Invites: stats.Invites, Bans: stats.Bans, } payload.Users.Total = stats.Users.Total payload.Users.Admins = stats.Users.Admins payload.Users.Banned = stats.Users.Banned payload.Users.New7Days = stats.Users.New7Days payload.Users.New30Days = stats.Users.New30Days payload.Users.Active24h = stats.Users.Active24h payload.Users.Daily = dailyCountsPayload(stats.Users.Daily) payload.Messages.Total = stats.Messages.Total payload.Messages.Today = stats.Messages.Today payload.Messages.Week = stats.Messages.Week payload.Messages.Month = stats.Messages.Month payload.Messages.Daily = dailyCountsPayload(stats.Messages.Daily) payload.Files.Count = stats.Files.Count payload.Files.Bytes = stats.Files.Bytes payload.TopGuilds = guildStatsPayload(stats.TopGuilds) payload.BusiestGuilds = guildStatsPayload(stats.BusiestGuilds) database := sysinfo.FileBytes(s.databasePath()) database += sysinfo.FileBytes(s.databasePath() + "-wal") payload.DatabaseBytes = database payload.StorageBytes = database + stats.Files.Bytes return payload } func dailyCountsPayload(counts []store.DailyCount) []dailyCountPayload { payload := make([]dailyCountPayload, 0, len(counts)) for _, item := range counts { payload = append(payload, dailyCountPayload{Date: item.Date, Count: item.Count}) } return payload } func guildStatsPayload(guilds []store.InstanceGuildStat) []instanceGuildStatPayload { payload := make([]instanceGuildStatPayload, 0, len(guilds)) for _, item := range guilds { payload = append(payload, instanceGuildStatPayload{ ID: formatSnowflake(item.ID), Name: item.Name, Members: item.Members, Messages: item.Messages, }) } return payload } // instanceUserCard собирает карточку пользователя для админ-панели: профиль, // активные устройства, серверы с ролями, события безопасности и аудит, где он // актор или цель (AGENT.md 7.18, 7.19). func (s *Server) instanceUserCard(ctx context.Context, target *store.User) (*instanceUserCardOutput, error) { output := &instanceUserCardOutput{} output.Body.User = instanceUserPayloadFrom(*target) output.Body.Sessions = []instanceSessionPayload{} output.Body.Guilds = []instanceMembershipPayload{} output.Body.SecurityEvents = []securityEventPayload{} sessions, err := s.store.ListSessions(ctx, target.ID) if err != nil { return nil, humaError(err) } for _, session := range sessions { output.Body.Sessions = append(output.Body.Sessions, instanceSessionPayload{ ID: formatSnowflake(session.ID), UserAgent: session.UserAgent, IP: session.IP, CreatedAt: session.CreatedAt.UTC().Format(time.RFC3339), LastSeen: session.LastSeen.UTC().Format(time.RFC3339), ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339), SteppedUp: session.SteppedUp(time.Now().UTC()), }) } memberships, err := s.store.ListUserGuildMemberships(ctx, target.ID) if err != nil { return nil, humaError(err) } for _, membership := range memberships { item := instanceMembershipPayload{ GuildID: formatSnowflake(membership.GuildID), GuildName: membership.GuildName, Nickname: membership.Nickname, JoinedAt: membership.JoinedAt.UTC().Format(time.RFC3339), Roles: []instanceMembershipRolePayload{}, } for _, role := range membership.Roles { item.Roles = append(item.Roles, instanceMembershipRolePayload{ ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color, }) } output.Body.Guilds = append(output.Body.Guilds, item) } events, err := s.store.ListSecurityEvents(ctx, target.ID, securityEventsLimit) if err != nil { return nil, humaError(err) } for _, event := range events { output.Body.SecurityEvents = append(output.Body.SecurityEvents, securityEventPayload{ ID: formatSnowflake(event.ID), Type: event.Type, IP: event.IP, UserAgent: event.UserAgent, Metadata: event.Metadata, CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339), }) } entries, total, err := s.store.ListUserAudit(ctx, target.ID, userAuditLimit, 0) if err != nil { return nil, humaError(err) } output.Body.Audit = auditPayloads(entries) output.Body.AuditTotal = total passkeys, err := s.store.CountPasskeys(ctx, target.ID) if err != nil { return nil, humaError(err) } output.Body.Passkeys = passkeys if secret, err := s.store.GetTOTPSecret(ctx, target.ID); err == nil { output.Body.TOTPEnabled = secret.Enabled } else if !errors.Is(err, store.ErrNotFound) { return nil, humaError(err) } return output, nil } // registerInstanceExportRoutes описывает выгрузку журнала инстанса: ответ — // CSV, а не JSON, поэтому ручка живёт на роутере chi, а не в huma. func (s *Server) registerInstanceExportRoutes(router chi.Router) { router.Get("/instance/audit/export", s.handleInstanceAuditExport) } // handleInstanceAuditExport отдаёт журнал инстанса файлом CSV с теми же // фильтрами, что и раздел «Аудит» (AGENT.md 7.18). Предел выгрузки — 5000 // строк, иначе один запрос вычитывал бы весь журнал в память. func (s *Server) handleInstanceAuditExport(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } if !user.IsInstanceAdmin { httpxWriteJSONError(w, http.StatusForbidden, "instance.admin_required", "instance administrator rights required") return } if allowed, retryAfter := s.auditExportLimiter.Allow("audit-export:" + formatSnowflake(user.ID)); !allowed { writeHumaAPIError(w, rateLimitedError(retryAfter)) return } query := r.URL.Query() filter, err := auditFilterFromQuery(auditQueryParams{ Action: query.Get("action"), ActorID: query.Get("actor_id"), TargetID: query.Get("target_id"), GuildID: query.Get("guild_id"), Since: query.Get("since"), Until: query.Get("until"), Limit: store.MaxInstanceAuditLimit, }) if err != nil { writeHumaAPIError(w, err) return } entries, _, err := s.store.ListInstanceAuditFiltered(r.Context(), filter) if err != nil { writeHumaAPIError(w, humaError(err)) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="audit-`+time.Now().UTC().Format("20060102-150405")+`.csv"`) w.Header().Set("X-Content-Type-Options", "nosniff") writer := csv.NewWriter(w) write := func(row []string) { safe := make([]string, 0, len(row)) for _, value := range row { safe = append(safe, csvSafe(value)) } _ = writer.Write(safe) } write([]string{ "id", "created_at", "action", "actor_id", "actor_instance_admin", "target_type", "target_id", "guild_id", "reason", "changes", }) for _, entry := range entries { write([]string{ formatSnowflake(entry.ID), entry.CreatedAt.UTC().Format(time.RFC3339), entry.Action, optionalSnowflake(entry.ActorID), strconv.FormatBool(entry.ActorInstanceAdmin), entry.TargetType, optionalSnowflake(entry.TargetID), optionalSnowflake(entry.GuildID), entry.Reason, string(entry.Changes), }) } writer.Flush() if err := writer.Error(); err != nil { s.logger.WarnContext(r.Context(), "audit export failed", slog.Any("error", err)) } s.recordAudit(r.Context(), user, 0, "instance.audit_export", "instance", nil, "") } // csvSafe защищает выгрузку от формул Excel: значения, начинающиеся с «=», // «+», «-» или «@», таблица воспринимает как формулу (CSV injection). func csvSafe(value string) string { if value == "" { return value } switch value[0] { case '=', '+', '-', '@': return "'" + value default: return value } } // optionalSnowflake печатает необязательный идентификатор. func optionalSnowflake(id *uint64) string { if id == nil { return "" } return formatSnowflake(*id) } // recordSecurityEvent пишет событие безопасности по действию администратора // инстанса: владелец аккаунта должен видеть в разделе безопасности, что его // устройство отозвали или сбросили второй фактор (AGENT.md 7.1, 7.19). Ошибка // записи не ломает основное действие. func (s *Server) recordSecurityEvent( ctx context.Context, userID *uint64, eventType string, session *store.Session, metadata map[string]any, ) { encoded := "{}" if len(metadata) > 0 { if raw, err := json.Marshal(metadata); err == nil { encoded = string(raw) } } var ip, userAgent string if session != nil { ip, userAgent = session.IP, session.UserAgent } if err := s.store.RecordSecurityEvent(ctx, userID, eventType, ip, userAgent, encoded); err != nil { s.logger.WarnContext(ctx, "failed to record security event", slog.Any("error", err)) } }