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:
2026-09-22 18:59:53 +03:00
parent d2712c4c99
commit 787f822dc0
15 changed files with 850 additions and 55 deletions
+18
View File
@@ -32,6 +32,9 @@ var (
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
// ErrUserBanned — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
// действующие сессии недействительны.
ErrUserBanned = errors.New("user.banned")
)
// Config — параметры сервиса аутентификации.
@@ -310,6 +313,15 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string
return nil, "", nil, err
}
// Глобальный бан инстанса проверяется до пароля: причина отказа важнее
// (AGENT.md 7.18). Бан инстанс-админа невозможен, поэтому порядок безопасен.
if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil {
return nil, "", nil, err
} else if banned {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", in.IP, in.UserAgent, "")
return nil, "", nil, ErrUserBanned
}
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
return nil, "", nil, ErrInvalidCredentials
@@ -355,6 +367,12 @@ func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User
if err != nil {
return nil, nil, ErrSessionExpired
}
// Бан инстанса обрывает уже выданные сессии: сессию удаляем, чтобы клиент
// не возвращался к ней повторно (AGENT.md 7.18).
if user.BannedAt != nil {
_ = s.store.DeleteSession(ctx, session.ID)
return nil, nil, ErrUserBanned
}
_ = s.store.TouchSession(ctx, session.ID)
return user, session, nil
}
@@ -0,0 +1,14 @@
-- +goose Up
-- Глобальные баны инстанса (AGENT.md 7.18, 7.19): администратор инстанса
-- блокирует аккаунт целиком, а не участие в отдельном сервере. Отдельная
-- таблица, а не колонка в users: причина и автор бана нужны для аудита, а
-- разбан должен сохранять историю (запись удаляется вместе с аккаунтом).
CREATE TABLE instance_bans (
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users (id) ON DELETE SET NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- +goose Down
DROP TABLE instance_bans;
+146 -15
View File
@@ -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,
+3
View File
@@ -35,6 +35,9 @@ func newAPIError(err error) apiError {
case errors.Is(err, auth.ErrInstanceAdminTOTP):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
case errors.Is(err, auth.ErrUserBanned):
candidate.Status, candidate.Code = http.StatusForbidden, "user.banned"
candidate.Message = "account is banned on this instance"
case errors.Is(err, auth.ErrSessionExpired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
case errors.Is(err, auth.ErrStepUpRequired):
+230
View File
@@ -0,0 +1,230 @@
package server
import (
"net/http"
"testing"
"glchat/internal/crypto"
)
// instanceUsers вызывается администратором: список пользователей панели.
func instanceUsers(t *testing.T, srv *Server, cookie *http.Cookie, query string) struct {
Users []struct {
ID string `json:"id"`
Username string `json:"username"`
Banned bool `json:"banned"`
Reason string `json:"ban_reason"`
} `json:"users"`
Total int `json:"total"`
} {
t.Helper()
path := "/api/v1/instance/users"
if query != "" {
path += "?" + query
}
rec := doJSON(t, srv, http.MethodGet, path, "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, body = %s", path, rec.Code, rec.Body.String())
}
return decodeResponse[struct {
Users []struct {
ID string `json:"id"`
Username string `json:"username"`
Banned bool `json:"banned"`
Reason string `json:"ban_reason"`
} `json:"users"`
Total int `json:"total"`
}](t, rec)
}
func TestInstanceBanBlocksLoginAndSessions(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "ban_admin", "ban-admin@example.com")
promoteAdmin(t, srv, "ban-admin@example.com")
userCookie := registerAndLogin(t, srv, "ban_target", "ban-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "ban-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID)
// Бан без step-up отклоняется: действие чувствительное (AGENT.md 7.1).
noStepUp := doJSON(t, srv, http.MethodPost, targetPath+"/ban", `{"reason":"спам"}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("ban without step-up = %d, want 403", noStepUp.Code)
}
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
t.Fatalf("ban without step-up code = %q", code)
}
banned := doJSON(t, srv, http.MethodPost, targetPath+"/ban",
`{"reason":"спам в общем канале","step_up_password":"correct-horse-battery"}`, adminCookie)
if banned.Code != http.StatusOK {
t.Fatalf("ban = %d, body = %s", banned.Code, banned.Body.String())
}
// Список панели показывает бан и причину, фильтр «только забаненные» работает.
list := instanceUsers(t, srv, adminCookie, "banned=true")
if len(list.Users) != 1 || list.Users[0].Username != "ban_target" || !list.Users[0].Banned {
t.Fatalf("banned filter = %+v", list.Users)
}
if list.Users[0].Reason != "спам в общем канале" {
t.Fatalf("ban reason = %q", list.Users[0].Reason)
}
// Вход запрещён с отдельным кодом, а не «неверный пароль».
login := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-target@example.com","password":"correct-horse-battery"}`)
if login.Code != http.StatusForbidden {
t.Fatalf("login as banned = %d, want 403, body = %s", login.Code, login.Body.String())
}
if code := errorCodeOf(t, login); code != "user.banned" {
t.Fatalf("login as banned code = %q", code)
}
// Выданная ранее сессия перестаёт работать и отозвана в базе.
me := doJSON(t, srv, http.MethodGet, "/api/v1/auth/sessions", "", userCookie)
if me.Code != http.StatusUnauthorized && me.Code != http.StatusForbidden {
t.Fatalf("banned session still works: %d, body = %s", me.Code, me.Body.String())
}
if _, err := srv.store.GetSessionByTokenHash(t.Context(), crypto.HashToken(userCookie.Value)); err == nil {
t.Fatal("session of banned user must be revoked")
}
// Разбан возвращает доступ: сессии не восстанавливаются, но вход снова работает.
unban := doJSON(t, srv, http.MethodPost, targetPath+"/unban",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if unban.Code != http.StatusOK {
t.Fatalf("unban = %d, body = %s", unban.Code, unban.Body.String())
}
loginAgain := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-target@example.com","password":"correct-horse-battery"}`)
if loginAgain.Code != http.StatusOK {
t.Fatalf("login after unban = %d, body = %s", loginAgain.Code, loginAgain.Body.String())
}
if list := instanceUsers(t, srv, adminCookie, ""); len(list.Users) != 2 || list.Total != 2 {
t.Fatalf("users after unban = %+v (total %d)", list.Users, list.Total)
}
// Действия попали в аудит инстанса.
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
entries := decodeResponse[struct {
Entries []struct {
Action string `json:"action"`
Reason string `json:"reason"`
ActorInstanceAdmin bool `json:"actor_instance_admin"`
} `json:"entries"`
}](t, audit)
if !hasAction(entries.Entries, "instance.user_ban") || !hasAction(entries.Entries, "instance.user_unban") {
t.Fatalf("audit has no ban entries: %+v", entries.Entries)
}
for _, entry := range entries.Entries {
if entry.Action == "instance.user_ban" && entry.Reason != "спам в общем канале" {
t.Fatalf("audit ban reason = %q", entry.Reason)
}
}
}
func TestInstanceBanProtectsAdmins(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "protect_admin", "protect-admin@example.com")
promoteAdmin(t, srv, "protect-admin@example.com")
registerAndLogin(t, srv, "protect_admin2", "protect-admin2@example.com")
promoteAdmin(t, srv, "protect-admin2@example.com")
admin, err := srv.auth.UserByEmail(t.Context(), "protect-admin@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
other, err := srv.auth.UserByEmail(t.Context(), "protect-admin2@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
// Инстанс-админа забанить нельзя (AGENT.md 7.19) — даже другому админу.
rec := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("ban another admin = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "instance.admin_protected" {
t.Fatalf("ban another admin code = %q", code)
}
// Себя банить тоже нельзя.
self := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+formatSnowflake(admin.ID)+"/ban",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if self.Code != http.StatusUnprocessableEntity {
t.Fatalf("self ban = %d, want 422", self.Code)
}
// Обычный пользователь не может банить вообще.
userCookie := registerAndLogin(t, srv, "protect_user", "protect-user@example.com")
denied := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban",
`{"step_up_password":"correct-horse-battery"}`, userCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("ban as user = %d, want 403", denied.Code)
}
if code := errorCodeOf(t, denied); code != "instance.admin_required" {
t.Fatalf("ban as user code = %q", code)
}
}
func TestInstanceUserSearch(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "search_admin", "search-admin@example.com")
promoteAdmin(t, srv, "search-admin@example.com")
registerAndLogin(t, srv, "search_alpha", "search-alpha@example.com")
registerAndLogin(t, srv, "search_beta", "search-beta@example.com")
found := instanceUsers(t, srv, adminCookie, "q=alpha")
if len(found.Users) != 1 || found.Users[0].Username != "search_alpha" {
t.Fatalf("search by username = %+v", found.Users)
}
if found.Total != 1 {
t.Fatalf("search total = %d, want 1", found.Total)
}
// Поиск идёт и по отображаемому имени (оно равно логину при регистрации).
byDisplay := instanceUsers(t, srv, adminCookie, "q=BETA")
if len(byDisplay.Users) != 1 || byDisplay.Users[0].Username != "search_beta" {
t.Fatalf("search is case-insensitive by display name = %+v", byDisplay.Users)
}
empty := instanceUsers(t, srv, adminCookie, "q=nobody-here")
if len(empty.Users) != 0 || empty.Total != 0 {
t.Fatalf("search with no matches = %+v (total %d)", empty.Users, empty.Total)
}
}
func TestInstanceDeleteClearsBan(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "clear_admin", "clear-admin@example.com")
promoteAdmin(t, srv, "clear-admin@example.com")
registerAndLogin(t, srv, "clear_target", "clear-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "clear-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID)
if rec := doJSON(t, srv, http.MethodPost, targetPath+"/ban",
`{"reason":"мусор","step_up_password":"correct-horse-battery"}`, adminCookie); rec.Code != http.StatusOK {
t.Fatalf("ban = %d, body = %s", rec.Code, rec.Body.String())
}
if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || !banned {
t.Fatalf("IsInstanceBanned = %v, %v; want true", banned, err)
}
// Мягкое удаление убирает и запись о бане: аккаунта больше нет.
if rec := doJSON(t, srv, http.MethodDelete, targetPath, "", adminCookie); rec.Code != http.StatusOK {
t.Fatalf("delete = %d, body = %s", rec.Code, rec.Body.String())
}
if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || banned {
t.Fatalf("IsInstanceBanned after delete = %v, %v; want false", banned, err)
}
}
+5
View File
@@ -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()
}
+3 -3
View File
@@ -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
View File
@@ -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
}