feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
+230
-14
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user