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") }