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:
@@ -94,5 +94,10 @@ func (s *Store) SoftDeleteUser(ctx context.Context, userID uint64) error {
|
||||
int64(userID), int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
// Глобальный бан аккаунта снимается вместе с аккаунтом: запись о бане
|
||||
// без пользователя только мешает разбору (`banned=true` в панели).
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM instance_bans WHERE user_id = ?`, int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -278,9 +278,9 @@ func (s *Store) SearchUsersByUsername(ctx context.Context, query string, exclude
|
||||
// ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный
|
||||
// символ, поэтому экранируем его и сообщаем об этом SQLite.
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT `+userColumns+` FROM users
|
||||
WHERE deleted_at IS NULL AND id <> ? AND username_lower LIKE ? ESCAPE '\'
|
||||
ORDER BY username_lower LIMIT ?`,
|
||||
SELECT `+userColumns+` FROM `+userFrom+`
|
||||
WHERE u.deleted_at IS NULL AND u.id <> ? AND u.username_lower LIKE ? ESCAPE '\'
|
||||
ORDER BY u.username_lower LIMIT ?`,
|
||||
int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+100
-12
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -35,6 +36,11 @@ type User struct {
|
||||
LastSeenAt *time.Time
|
||||
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
||||
Timezone string
|
||||
// BannedAt — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
|
||||
// сессии отозваны. Причина хранится рядом, в BanReason.
|
||||
BannedAt *time.Time
|
||||
// BanReason — причина глобального бана (пустая, если бана нет).
|
||||
BanReason string
|
||||
}
|
||||
|
||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||
@@ -49,10 +55,14 @@ type CreateUserParams struct {
|
||||
Locale string
|
||||
}
|
||||
|
||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
|
||||
onboarding_completed_at, last_seen_at, timezone`
|
||||
const userColumns = `u.id, u.username, u.display_name, u.email_enc, u.password_hash, u.avatar_file_id,
|
||||
u.banner_file_id, u.bio, u.status, u.custom_status, u.custom_status_emoji, u.flags,
|
||||
u.is_instance_admin, u.badges_json, u.locale, u.created_at, u.updated_at, u.deleted_at,
|
||||
u.onboarding_completed_at, u.last_seen_at, u.timezone, b.created_at, COALESCE(b.reason, '')`
|
||||
|
||||
// userFrom — источник строк для userColumns: бан инстанса подмешивается
|
||||
// левым соединением, чтобы модель всегда знала о блокировке.
|
||||
const userFrom = `users u LEFT JOIN instance_bans b ON b.user_id = u.id`
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -83,13 +93,13 @@ func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User,
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id))
|
||||
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM `+userFrom+` WHERE u.id = ? AND u.deleted_at IS NULL`, int64(id))
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`,
|
||||
`SELECT `+userColumns+` FROM `+userFrom+` WHERE u.username_lower = ? AND u.deleted_at IS NULL`,
|
||||
strings.ToLower(username))
|
||||
return scanUser(row)
|
||||
}
|
||||
@@ -98,7 +108,7 @@ func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User,
|
||||
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
|
||||
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex)
|
||||
`SELECT `+userColumns+` FROM `+userFrom+` WHERE u.email_index = ? AND u.deleted_at IS NULL`, emailIndex)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
@@ -247,17 +257,36 @@ func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
|
||||
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
||||
// userFilter собирает WHERE для списка пользователей: поиск по логину и
|
||||
// отображаемому имени плюс фильтр «только забаненные» (AGENT.md 7.18).
|
||||
func userFilter(query string, bannedOnly bool) (string, []any) {
|
||||
where := []string{"u.deleted_at IS NULL"}
|
||||
args := []any{}
|
||||
if trimmed := strings.TrimSpace(query); trimmed != "" {
|
||||
pattern := "%" + strings.ToLower(trimmed) + "%"
|
||||
where = append(where, "(u.username_lower LIKE ? OR lower(u.display_name) LIKE ?)")
|
||||
args = append(args, pattern, pattern)
|
||||
}
|
||||
if bannedOnly {
|
||||
where = append(where, "b.user_id IS NOT NULL")
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
// ListUsers отдаёт страницу пользователей для админ-панели инстанса
|
||||
// (AGENT.md 6.5): поиск по логину и имени, опционально только забаненные.
|
||||
func (s *Store) ListUsers(ctx context.Context, query string, bannedOnly bool, limit, offset int) ([]User, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
where, args := userFilter(query, bannedOnly)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?`,
|
||||
limit, offset)
|
||||
`SELECT `+userColumns+` FROM `+userFrom+` WHERE `+where+` ORDER BY u.id LIMIT ? OFFSET ?`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -274,6 +303,58 @@ func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// CountUsersFiltered — сколько пользователей попадает в тот же фильтр: нужно
|
||||
// панели для пагинации.
|
||||
func (s *Store) CountUsersFiltered(ctx context.Context, query string, bannedOnly bool) (int, error) {
|
||||
where, args := userFilter(query, bannedOnly)
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM `+userFrom+` WHERE `+where, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// BanInstanceUser блокирует аккаунт целиком (AGENT.md 7.18). Повторный бан
|
||||
// обновляет причину и автора.
|
||||
func (s *Store) BanInstanceUser(ctx context.Context, userID, actorID uint64, reason string) error {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO instance_bans (user_id, actor_id, reason, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET actor_id = excluded.actor_id,
|
||||
reason = excluded.reason, created_at = excluded.created_at`,
|
||||
int64(userID), int64(actorID), reason, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnbanInstanceUser снимает глобальный бан. Отсутствие бана не ошибка.
|
||||
func (s *Store) UnbanInstanceUser(ctx context.Context, userID uint64) (bool, error) {
|
||||
result, err := s.writer.ExecContext(ctx, `DELETE FROM instance_bans WHERE user_id = ?`, int64(userID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// IsInstanceBanned проверяет глобальный бан по идентификатору пользователя.
|
||||
func (s *Store) IsInstanceBanned(ctx context.Context, userID uint64) (bool, error) {
|
||||
var exists int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM instance_bans WHERE user_id = ?`, int64(userID)).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
var (
|
||||
user User
|
||||
@@ -287,12 +368,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
onboardingAt sql.NullString
|
||||
lastSeenAt sql.NullString
|
||||
emailEncrypted string
|
||||
bannedAt sql.NullString
|
||||
banReason string
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||
&onboardingAt, &lastSeenAt, &user.Timezone,
|
||||
&onboardingAt, &lastSeenAt, &user.Timezone, &bannedAt, &banReason,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
@@ -326,6 +409,11 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
if user.Timezone == "" {
|
||||
user.Timezone = "Europe/Moscow"
|
||||
}
|
||||
if bannedAt.Valid {
|
||||
value := parseTimestamp(bannedAt.String)
|
||||
user.BannedAt = &value
|
||||
user.BanReason = banReason
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user