diff --git a/internal/auth/service.go b/internal/auth/service.go index ee91edb..97a1d3b 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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 } diff --git a/internal/database/migrations/00017_instance_bans.sql b/internal/database/migrations/00017_instance_bans.sql new file mode 100644 index 0000000..158b8b1 --- /dev/null +++ b/internal/database/migrations/00017_instance_bans.sql @@ -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; diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index 35d8d68..74948fb 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -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, diff --git a/internal/server/errors.go b/internal/server/errors.go index e600c22..9f2e393 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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 ` 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): diff --git a/internal/server/instance_ban_test.go b/internal/server/instance_ban_test.go new file mode 100644 index 0000000..b4053e0 --- /dev/null +++ b/internal/server/instance_ban_test.go @@ -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) + } +} diff --git a/internal/store/retention.go b/internal/store/retention.go index a2df12b..07ffd1e 100644 --- a/internal/store/retention.go +++ b/internal/store/retention.go @@ -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() } diff --git a/internal/store/social.go b/internal/store/social.go index 49eee90..0628a49 100644 --- a/internal/store/social.go +++ b/internal/store/social.go @@ -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 diff --git a/internal/store/users.go b/internal/store/users.go index 444fa7f..138a214 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 } diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts index bf824f8..d290c9c 100644 --- a/web/src/api/instance.ts +++ b/web/src/api/instance.ts @@ -57,12 +57,59 @@ export async function fetchInstanceGuilds(signal?: AbortSignal): Promise { - const payload = await request<{ users: InstanceUser[] }>( - '/instance/users', +export interface InstanceUserFilter { + /** Поиск по логину и отображаемому имени. */ + query?: string; + /** Показать только забаненных. */ + bannedOnly?: boolean; + limit?: number; + offset?: number; +} + +/** Ключ списка пользователей: фильтр входит в ключ, иначе кэш смешает выборки. */ +export function instanceUserListQueryKey(filter: InstanceUserFilter = {}) { + return [...instanceUsersQueryKey, filter.query ?? '', filter.bannedOnly === true] as const; +} + +export async function fetchInstanceUsers( + filter: InstanceUserFilter = {}, + signal?: AbortSignal, +): Promise<{ users: InstanceUser[]; total: number }> { + const params = new URLSearchParams(); + if (filter.query !== undefined && filter.query !== '') { + params.set('q', filter.query); + } + if (filter.bannedOnly === true) { + params.set('banned', 'true'); + } + if (filter.limit !== undefined) { + params.set('limit', String(filter.limit)); + } + if (filter.offset !== undefined) { + params.set('offset', String(filter.offset)); + } + const suffix = params.size === 0 ? '' : `?${params.toString()}`; + const payload = await request<{ users: InstanceUser[]; total: number }>( + `/instance/users${suffix}`, signal === undefined ? {} : { signal }, ); - return payload.users; + return { users: payload.users, total: payload.total }; +} + +/** + * `POST /instance/users/{id}/ban` — глобальный бан инстанса (AGENT.md 7.18): + * аккаунт не может войти, все сессии отзываются. + */ +export async function banUser(userId: string, reason: string): Promise { + await request(`/instance/users/${encodeURIComponent(userId)}/ban`, { + method: 'POST', + body: { reason }, + }); +} + +/** `POST /instance/users/{id}/unban` — снять глобальный бан. */ +export async function unbanUser(userId: string): Promise { + await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' }); } export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index f3a229e..61cb652 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -189,6 +189,10 @@ export interface InstanceUser { display_name: string; is_instance_admin: boolean; created_at: string; + /** Глобальный бан инстанса (AGENT.md 7.18). */ + banned: boolean; + banned_at?: string; + ban_reason?: string; } export interface AuditEntry { diff --git a/web/src/components/instance/InstanceUsersPanel.tsx b/web/src/components/instance/InstanceUsersPanel.tsx index bc57e97..f5a907c 100644 --- a/web/src/components/instance/InstanceUsersPanel.tsx +++ b/web/src/components/instance/InstanceUsersPanel.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance'; +import type { InstanceUser } from '@/api/types'; import { Button } from '@/components/ui/primitives'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; @@ -11,10 +12,10 @@ import { useCurrentUser } from '@/lib/hooks'; import { useEffect } from 'react'; interface InstanceUsersPanelProps { - /** Свой id: себя удалять нельзя (сервер тоже это проверяет). */ + /** Свой id: себя удалять и банить нельзя (сервер тоже это проверяет). */ currentUserId: string | undefined; /** Признак, что список загружен: панель показывает действия. */ - users: { id: string; username: string; display_name: string; is_instance_admin: boolean }[]; + users: InstanceUser[]; isPending: boolean; error: unknown; onRetry: () => void; @@ -23,8 +24,24 @@ interface InstanceUsersPanelProps { adminPending: boolean; /** Ошибка смены прав: сервер требует step-up или отказывает. */ adminError?: unknown; + /** Глобальный бан и разбан (AGENT.md 7.18). */ + onBan: (userId: string, reason: string) => void; + onUnban: (userId: string) => void; + banPending: boolean; + banError?: unknown; + /** Поиск по логину и имени и фильтр «только забаненные». */ + search: string; + onSearchChange: (value: string) => void; + bannedOnly: boolean; + onBannedOnlyChange: (value: boolean) => void; } +/** Действие, для которого запрашивается подтверждение личности. */ +type StepUpAction = + | { kind: 'admin'; userId: string; makeAdmin: boolean } + | { kind: 'ban'; userId: string; reason: string } + | { kind: 'unban'; userId: string }; + /** * Действия администратора инстанса над пользователем (AGENT.md 7.19): * временный пароль, выход со всех устройств и мягкое удаление. @@ -38,6 +55,14 @@ export function InstanceUsersPanel({ onToggleAdmin, adminPending, adminError, + onBan, + onUnban, + banPending, + banError, + search, + onSearchChange, + bannedOnly, + onBannedOnlyChange, }: InstanceUsersPanelProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -45,8 +70,10 @@ export function InstanceUsersPanel({ const [password, setPassword] = useState<{ userId: string; value: string } | null>(null); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); - // Смена администраторов требует подтверждения личности (AGENT.md 7.1). - const [stepUpFor, setStepUpFor] = useState<{ userId: string; makeAdmin: boolean } | null>(null); + // Чувствительные действия требуют подтверждения личности (AGENT.md 7.1). + const [stepUpFor, setStepUpFor] = useState(null); + // Причина бана вводится до подтверждения личности. + const [banFor, setBanFor] = useState<{ userId: string; reason: string } | null>(null); const [stepUpPassword, setStepUpPassword] = useState(''); const [stepUpCode, setStepUpCode] = useState(''); @@ -56,9 +83,17 @@ export function InstanceUsersPanel({ const target = stepUpFor; setStepUpPassword(''); setStepUpCode(''); - if (target !== null) { - onToggleAdmin(target.userId, target.makeAdmin); + if (target === null) { + return; } + if (target.kind === 'admin') { + onToggleAdmin(target.userId, target.makeAdmin); + } else if (target.kind === 'ban') { + onBan(target.userId, target.reason); + } else { + onUnban(target.userId); + } + setStepUpFor(null); }, }); @@ -103,7 +138,8 @@ export function InstanceUsersPanel({ onError: (cause: unknown) => setActionError(cause), }); - const pending = reset.isPending || logout.isPending || remove.isPending || adminPending; + const pending = + reset.isPending || logout.isPending || remove.isPending || adminPending || banPending; if (isPending) { return

{t('common.loading')}

; @@ -119,6 +155,7 @@ export function InstanceUsersPanel({
{actionError === null ? null : } {adminError === null || adminError === undefined ? null : } + {banError === null || banError === undefined ? null : } {notice === null ? null : (

{notice} @@ -172,6 +209,25 @@ export function InstanceUsersPanel({

)} +
+ onSearchChange(event.target.value)} + /> + +
+
    {users.map((user) => { const self = user.id === currentUserId; @@ -195,6 +251,45 @@ export function InstanceUsersPanel({ {t('settings.instance.adminBadge')} ) : null} + {user.banned ? ( + + {t('settings.instance.bannedBadge')} + + ) : null} + + {banFor !== null && banFor.userId === user.id ? ( +
    { + event.preventDefault(); + setStepUpFor({ kind: 'ban', userId: user.id, reason: banFor.reason }); + setBanFor(null); + }} + > + setBanFor({ userId: user.id, reason: event.target.value })} + /> + + + + ) : null} + {/* Инстанс-админа и себя банить нельзя (AGENT.md 7.19). */} +