feat(instance): глобальный бан пользователя и поиск в админ-панели
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.
Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
«Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
размонтировалось на первом же символе и набор обрывался.
Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
This commit is contained in:
+146
-15
@@ -59,14 +59,41 @@ type instanceUserPayload struct {
|
||||
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"`
|
||||
@@ -345,33 +372,30 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||
Offset int `query:"offset" default:"0" minimum:"0"`
|
||||
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.Limit, input.Offset)
|
||||
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 {
|
||||
badges := user.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
output.Body.Users = append(output.Body.Users, instanceUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: badges,
|
||||
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
output.Body.Users = append(output.Body.Users, instanceUserPayloadFrom(user))
|
||||
}
|
||||
output.Body.Total = total
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -501,6 +525,113 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
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.SendToUser(target.ID, "SESSION_INVALIDATED", map[string]any{"reason": "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,
|
||||
|
||||
Reference in New Issue
Block a user