Files
grendervill 3dc200c196 feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
2026-09-26 16:44:49 +03:00

1527 lines
55 KiB
Go

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))
}
}