787f822dc0
Сервер:
- миграция 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.
432 lines
14 KiB
Go
432 lines
14 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// User — аккаунт пользователя. Email в БД лежит зашифрованным (AGENT.md 9.2),
|
|
// поэтому в модели он заполняется только тогда, когда реально расшифрован.
|
|
type User struct {
|
|
ID uint64
|
|
Username string
|
|
DisplayName string
|
|
Email string
|
|
PasswordHash string
|
|
AvatarFileID *uint64
|
|
BannerFileID *uint64
|
|
Bio string
|
|
Status string
|
|
CustomStatus string
|
|
CustomStatusEmoji string
|
|
Flags int64
|
|
IsInstanceAdmin bool
|
|
Badges []string
|
|
Locale string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
DeletedAt *time.Time
|
|
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
|
|
OnboardingCompletedAt *time.Time
|
|
// LastSeenAt — время последней активности (для статусов друзей).
|
|
LastSeenAt *time.Time
|
|
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
|
Timezone string
|
|
// BannedAt — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
|
|
// сессии отозваны. Причина хранится рядом, в BanReason.
|
|
BannedAt *time.Time
|
|
// BanReason — причина глобального бана (пустая, если бана нет).
|
|
BanReason string
|
|
}
|
|
|
|
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
|
// выполняет вызывающий код (internal/auth), чтобы store не знал про ключи.
|
|
type CreateUserParams struct {
|
|
ID uint64
|
|
Username string
|
|
DisplayName string
|
|
EmailEnc string
|
|
EmailIndex string
|
|
PasswordHash string
|
|
Locale string
|
|
}
|
|
|
|
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 {
|
|
params.ID = s.NextID()
|
|
}
|
|
if params.DisplayName == "" {
|
|
params.DisplayName = params.Username
|
|
}
|
|
if params.Locale == "" {
|
|
params.Locale = "ru"
|
|
}
|
|
ts := s.Now()
|
|
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
|
|
password_hash, locale, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
int64(params.ID), params.Username, strings.ToLower(params.Username), params.DisplayName,
|
|
params.EmailEnc, params.EmailIndex, params.PasswordHash, params.Locale, ts, ts,
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrConflict
|
|
}
|
|
return nil, err
|
|
}
|
|
return s.GetUser(ctx, params.ID)
|
|
}
|
|
|
|
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
|
|
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 `+userFrom+` WHERE u.username_lower = ? AND u.deleted_at IS NULL`,
|
|
strings.ToLower(username))
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetUserByEmailIndex ищет пользователя по blind index: сам email в запросе
|
|
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
|
|
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
|
|
row := s.reader.QueryRowContext(ctx,
|
|
`SELECT `+userColumns+` FROM `+userFrom+` WHERE u.email_index = ? AND u.deleted_at IS NULL`, emailIndex)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// EncryptedEmail возвращает сохранённый шифротекст email для расшифровки.
|
|
func (s *Store) EncryptedEmail(ctx context.Context, id uint64) (string, error) {
|
|
var encrypted string
|
|
err := s.reader.QueryRowContext(ctx, `SELECT email_enc FROM users WHERE id = ?`, int64(id)).Scan(&encrypted)
|
|
if err != nil {
|
|
return "", mapError(err)
|
|
}
|
|
return encrypted, nil
|
|
}
|
|
|
|
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
|
var count int
|
|
if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count); err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
type UpdateUserParams struct {
|
|
DisplayName *string
|
|
Bio *string
|
|
Status *string
|
|
CustomStatus *string
|
|
CustomStatusEmoji *string
|
|
AvatarFileID *uint64
|
|
BannerFileID *uint64
|
|
Locale *string
|
|
}
|
|
|
|
func (s *Store) UpdateUser(ctx context.Context, id uint64, params UpdateUserParams) (*User, error) {
|
|
var (
|
|
sets []string
|
|
args []any
|
|
)
|
|
add := func(column string, value any) {
|
|
sets = append(sets, column+" = ?")
|
|
args = append(args, value)
|
|
}
|
|
if params.DisplayName != nil {
|
|
add("display_name", *params.DisplayName)
|
|
}
|
|
if params.Bio != nil {
|
|
add("bio", *params.Bio)
|
|
}
|
|
if params.Status != nil {
|
|
add("status", *params.Status)
|
|
}
|
|
if params.CustomStatus != nil {
|
|
add("custom_status", *params.CustomStatus)
|
|
}
|
|
if params.CustomStatusEmoji != nil {
|
|
add("custom_status_emoji", *params.CustomStatusEmoji)
|
|
}
|
|
if params.AvatarFileID != nil {
|
|
add("avatar_file_id", int64(*params.AvatarFileID))
|
|
}
|
|
if params.BannerFileID != nil {
|
|
add("banner_file_id", int64(*params.BannerFileID))
|
|
}
|
|
if params.Locale != nil {
|
|
add("locale", *params.Locale)
|
|
}
|
|
if len(sets) == 0 {
|
|
return s.GetUser(ctx, id)
|
|
}
|
|
add("updated_at", s.Now())
|
|
args = append(args, int64(id))
|
|
|
|
result, err := s.writer.ExecContext(ctx, `UPDATE users SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
return s.GetUser(ctx, id)
|
|
}
|
|
|
|
// UpdateUserPassword меняет хэш пароля и возвращает прежнее значение, чтобы
|
|
// вызывающий код мог отозвать все сессии (AGENT.md 7.1).
|
|
func (s *Store) UpdateUserPassword(ctx context.Context, id uint64, passwordHash string) error {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
passwordHash, s.Now(), int64(id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetInstanceAdmin(ctx context.Context, id uint64, admin bool) error {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE users SET is_instance_admin = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
boolToInt(admin), s.Now(), int64(id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) error {
|
|
encoded, err := json.Marshal(badges)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE users SET badges_json = ?, updated_at = ? WHERE id = ?`,
|
|
string(encoded), s.Now(), int64(id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkOnboardingCompleted фиксирует завершение первичной настройки (AGENT.md 7.2).
|
|
func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE users SET onboarding_completed_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
s.Now(), s.Now(), int64(id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearAvatar убирает ссылку на файл аватара.
|
|
func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
|
|
_, err := s.writer.ExecContext(ctx,
|
|
`UPDATE users SET avatar_file_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
s.Now(), int64(id))
|
|
return err
|
|
}
|
|
|
|
// 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 `+userFrom+` WHERE `+where+` ORDER BY u.id LIMIT ? OFFSET ?`,
|
|
args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := make([]User, 0, limit)
|
|
for rows.Next() {
|
|
user, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, *user)
|
|
}
|
|
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
|
|
avatarID sql.NullInt64
|
|
bannerID sql.NullInt64
|
|
isAdmin int
|
|
badges string
|
|
createdAt string
|
|
updatedAt string
|
|
deletedAt sql.NullString
|
|
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, &bannedAt, &banReason,
|
|
)
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
if avatarID.Valid {
|
|
value := uint64(avatarID.Int64)
|
|
user.AvatarFileID = &value
|
|
}
|
|
if bannerID.Valid {
|
|
value := uint64(bannerID.Int64)
|
|
user.BannerFileID = &value
|
|
}
|
|
user.IsInstanceAdmin = isAdmin == 1
|
|
if err := json.Unmarshal([]byte(badges), &user.Badges); err != nil {
|
|
user.Badges = nil
|
|
}
|
|
user.CreatedAt = parseTimestamp(createdAt)
|
|
user.UpdatedAt = parseTimestamp(updatedAt)
|
|
if deletedAt.Valid {
|
|
value := parseTimestamp(deletedAt.String)
|
|
user.DeletedAt = &value
|
|
}
|
|
if onboardingAt.Valid {
|
|
value := parseTimestamp(onboardingAt.String)
|
|
user.OnboardingCompletedAt = &value
|
|
}
|
|
if lastSeenAt.Valid {
|
|
value := parseTimestamp(lastSeenAt.String)
|
|
user.LastSeenAt = &value
|
|
}
|
|
if user.Timezone == "" {
|
|
user.Timezone = "Europe/Moscow"
|
|
}
|
|
if bannedAt.Valid {
|
|
value := parseTimestamp(bannedAt.String)
|
|
user.BannedAt = &value
|
|
user.BanReason = banReason
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func parseTimestamp(value string) time.Time {
|
|
for _, layout := range []string{"2006-01-02T15:04:05.000Z", time.RFC3339Nano, time.RFC3339} {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed.UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
|
}
|