feat(instance): админ-панель с лимитами медиа и действиями над пользователями

- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
2026-09-21 22:37:47 +03:00
parent dd4432452c
commit ef871bcd96
20 changed files with 1617 additions and 204 deletions
+230 -14
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"crypto/rand"
"net/http"
"strconv"
"strings"
@@ -72,6 +73,36 @@ type instanceSettingsPayload struct {
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 {
@@ -122,13 +153,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
return nil, humaError(err)
}
output := &instanceSettingsOutput{}
output.Body.Settings = instanceSettingsPayload{
RegistrationEnabled: settings.RegistrationEnabled,
AllowGuildCreation: settings.AllowGuildCreation,
MaxGuildsPerUser: settings.MaxGuildsPerUser,
MaxMembersPerGuild: settings.MaxMembersPerGuild,
MaxMessageLength: settings.MaxMessageLength,
}
output.Body.Settings = instanceSettingsFromStore(settings)
return output, nil
})
@@ -146,6 +171,12 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
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) {
@@ -169,6 +200,17 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
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)
@@ -180,13 +222,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
}
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
output := &instanceSettingsOutput{}
output.Body.Settings = instanceSettingsPayload{
RegistrationEnabled: settings.RegistrationEnabled,
AllowGuildCreation: settings.AllowGuildCreation,
MaxGuildsPerUser: settings.MaxGuildsPerUser,
MaxMembersPerGuild: settings.MaxMembersPerGuild,
MaxMessageLength: settings.MaxMessageLength,
}
output.Body.Settings = instanceSettingsFromStore(settings)
return output, nil
})
@@ -392,6 +428,166 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
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)
}
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)
}
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
return newOKOutput(), 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.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "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,
@@ -473,3 +669,23 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use
}
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
}