diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index a0dd0f4..fc9e55d 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -161,6 +161,13 @@ func run() error { } else if updated > 0 { logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated)) } + // То же для названий серверов: поиск в админ-панели инстанса ищет по + // нормализованной копии (AGENT.md 7.18). + if updated, err := st.BackfillGuildNameLower(ctx, 500); err != nil { + logger.WarnContext(ctx, "backfill guild names failed", slog.Any("error", err)) + } else if updated > 0 { + logger.InfoContext(ctx, "guild names backfilled", slog.Int("guilds", updated)) + } srv := server.New(cfg, db, logger, server.Deps{ Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, diff --git a/internal/database/migrations/00025_guilds_name_lower.sql b/internal/database/migrations/00025_guilds_name_lower.sql new file mode 100644 index 0000000..4378d4d --- /dev/null +++ b/internal/database/migrations/00025_guilds_name_lower.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Поиск серверов в админ-панели инстанса без учёта регистра (AGENT.md 7.18): +-- SQLite lower() не знает кириллицу, поэтому нормализованную копию названия +-- приводит приложение — тем же приёмом, что и текст сообщений (00007). +-- Значение заполняется при создании и переименовании сервера, а для старых +-- записей — разовым backfill при старте (store.BackfillGuildSearchIndex). +ALTER TABLE guilds ADD COLUMN name_lower TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE guilds DROP COLUMN name_lower; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index ec1fae9..0ce0383 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -415,6 +415,33 @@ func (s *Service) InvalidateUserExcept(userID uint64, keepTokenHash, reason stri s.invalidateUser(userID, keepTokenHash, reason) } +// InvalidateSession закрывает соединение одной сессии пользователя: панель +// инстанса отзывает конкретное устройство (AGENT.md 7.18), остальные +// устройства продолжают работу. Соединение опознаётся по хэшу токена сессии — +// как и в InvalidateUserExcept, только условие обратное. +func (s *Service) InvalidateSession(userID uint64, tokenHash, reason string) { + if tokenHash == "" { + return + } + s.mu.Lock() + targets := make([]*clientSession, 0, 1) + for _, session := range s.sessions { + if session.userID != userID { + continue + } + if session.buffer == nil || session.buffer.tokenHash != tokenHash { + continue + } + targets = append(targets, session) + } + s.mu.Unlock() + for _, session := range targets { + // Сначала кадр (writeSync дожидается записи), потом закрытие. + s.sendInvalidSession(session, reason) + session.close() + } +} + func (s *Service) invalidateUser(userID uint64, keepTokenHash, reason string) { s.mu.Lock() targets := make([]*clientSession, 0, 2) diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index 9441044..f316d12 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -3,15 +3,21 @@ package server import ( "context" "crypto/rand" + "encoding/csv" + "encoding/json" + "errors" + "log/slog" "net/http" "strconv" "strings" "time" "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" "glchat/internal/bootstrap" "glchat/internal/store" + "glchat/internal/sysinfo" ) // instancePayload — публичная информация об инстансе (AGENT.md 6.5). @@ -49,6 +55,9 @@ type instanceGuildPayload struct { type instanceGuildListOutput struct { Body struct { Guilds []instanceGuildPayload `json:"guilds"` + // Total — сколько серверов попадает в фильтр: панель листает страницы + // (AGENT.md 7.18). + Total int `json:"total"` } } @@ -260,32 +269,51 @@ func (s *Server) registerInstanceRoutes(api huma.API) { Summary: "Все серверы инстанса", Tags: []string{"Instance"}, Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, - }, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) { + }, func(ctx context.Context, input *struct { + // Фильтры списка серверов (AGENT.md 7.18): поиск по названию, владелец, + // только главный сервер, плюс страница выдачи. + Query string `query:"q" maxLength:"64"` + OwnerID string `query:"owner_id"` + Main bool `query:"main"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + Offset int `query:"offset" default:"0" minimum:"0"` + }, + ) (*instanceGuildListOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } - guilds, err := s.store.ListAllGuilds(ctx) + var ownerID uint64 + if input.OwnerID != "" { + parsed, err := parseID("owner_id", input.OwnerID) + if err != nil { + return nil, err + } + ownerID = parsed + } + rows, total, err := s.store.ListInstanceGuilds(ctx, store.InstanceGuildFilter{ + Query: input.Query, + OwnerID: ownerID, + MainOnly: input.Main, + Limit: input.Limit, + Offset: input.Offset, + }) if err != nil { return nil, humaError(err) } output := &instanceGuildListOutput{} - output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds)) - for _, guild := range guilds { - payload := instanceGuildPayload{ - ID: formatSnowflake(guild.ID), - Name: guild.Name, - OwnerID: formatSnowflake(guild.OwnerID), - IsMain: guild.IsMain, - CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339), - } - if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil { - payload.OwnerName = owner.Username - } - if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { - payload.MemberCount = count - } - output.Body.Guilds = append(output.Body.Guilds, payload) + output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows)) + for _, row := range rows { + output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{ + ID: formatSnowflake(row.ID), + Name: row.Name, + OwnerID: formatSnowflake(row.OwnerID), + OwnerName: row.OwnerName, + IsMain: row.IsMain, + MemberCount: row.MemberCount, + CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), + }) } + output.Body.Total = total return output, nil }) @@ -733,18 +761,238 @@ 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"` + // Фильтры журнала (AGENT.md 7.10, 7.18): действие, актор, цель, сервер + // и период. Даты — в формате YYYY-MM-DD (UTC). + Action string `query:"action" maxLength:"64"` + ActorID string `query:"actor_id"` + TargetID string `query:"target_id"` + GuildID string `query:"guild_id"` + Since string `query:"since" maxLength:"10"` + Until string `query:"until" maxLength:"10"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + Offset int `query:"offset" default:"0" minimum:"0"` }, - ) (*auditListOutput, error) { + ) (*instanceAuditOutput, error) { if _, err := requireInstanceAdmin(ctx); err != nil { return nil, err } - entries, err := s.store.ListInstanceAudit(ctx, input.Limit) + filter, err := auditFilterFromQuery(auditQueryParams{ + Action: input.Action, ActorID: input.ActorID, TargetID: input.TargetID, + GuildID: input.GuildID, Since: input.Since, Until: input.Until, + Limit: input.Limit, Offset: input.Offset, + }) + if err != nil { + return nil, err + } + entries, total, err := s.store.ListInstanceAuditFiltered(ctx, filter) if err != nil { return nil, humaError(err) } - output := &auditListOutput{} + output := &instanceAuditOutput{} output.Body.Entries = auditPayloads(entries) + output.Body.Total = total + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listInstanceAuditActions", + Method: http.MethodGet, + Path: "/instance/audit/actions", + Summary: "Действия, встречающиеся в журнале инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, _ *struct{}) (*instanceAuditActionsOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + actions, err := s.store.ListInstanceAuditActions(ctx) + if err != nil { + return nil, humaError(err) + } + output := &instanceAuditActionsOutput{} + output.Body.Actions = actions + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getInstanceStats", + Method: http.MethodGet, + Path: "/instance/stats", + Summary: "Статистика инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, _ *struct{}) (*instanceStatsOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + stats, err := s.store.InstanceStats(ctx) + if err != nil { + return nil, humaError(err) + } + output := &instanceStatsOutput{} + output.Body.Stats = s.instanceStatsPayload(stats) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getInstanceUser", + Method: http.MethodGet, + Path: "/instance/users/{user_id}", + Summary: "Карточка пользователя для админ-панели", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + }, + ) (*instanceUserCardOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + target, err := s.store.GetUser(ctx, targetID) + if err != nil { + return nil, humaError(err) + } + card, err := s.instanceUserCard(ctx, target) + if err != nil { + return nil, err + } + return card, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminRevokeUserSession", + Method: http.MethodDelete, + Path: "/instance/users/{user_id}/sessions/{session_id}", + Summary: "Отозвать одно устройство пользователя", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + SessionID string `path:"session_id"` + Body struct { + // StepUpPassword подтверждает действие: отзыв устройства — + // чувствительная операция (AGENT.md 7.1, 7.18). + StepUpPassword string `json:"step_up_password,omitempty"` + StepUpTOTP string `json:"step_up_totp,omitempty"` + } + }, + ) (*okOutput, 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") + } + if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { + return nil, humaError(err) + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + sessionID, err := parseID("session_id", input.SessionID) + if err != nil { + return nil, err + } + targetSession, err := s.store.UserSession(ctx, targetID, sessionID) + if err != nil { + return nil, humaError(err) + } + deleted, err := s.store.DeleteUserSession(ctx, targetID, sessionID) + if err != nil { + return nil, humaError(err) + } + if !deleted { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "session not found") + } + // Закрываем только это соединение: остальные устройства пользователя + // продолжают работу (AGENT.md 11.6). + if s.gateway != nil { + s.gateway.InvalidateSession(targetID, targetSession.TokenHash, "session_revoked") + } + s.recordSecurityEvent(ctx, &targetID, "session_revoked", session, map[string]any{ + "by": "instance_admin", + "session_id": formatSnowflake(sessionID), + }) + s.recordAudit(ctx, admin, 0, "instance.user_session_revoke", "user", &targetID, "") + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminResetUser2FA", + Method: http.MethodPost, + Path: "/instance/users/{user_id}/reset-2fa", + Summary: "Сбросить второй фактор пользователя", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + Body struct { + // Сброс второго фактора — восстановление доступа: без step-up + // администратора это был бы готовый способ захвата аккаунта. + StepUpPassword string `json:"step_up_password,omitempty"` + StepUpTOTP string `json:"step_up_totp,omitempty"` + } + }, + ) (*adminReset2FAOutput, 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") + } + if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { + return nil, humaError(err) + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if targetID == admin.ID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "use the security settings to reset your own second factor") + } + 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 second factor cannot be reset") + } + secret, err := s.store.GetTOTPSecret(ctx, targetID) + if errors.Is(err, store.ErrNotFound) || (err == nil && !secret.Enabled) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "two-factor authentication is not enabled") + } + if err != nil { + return nil, humaError(err) + } + if err := s.store.DeleteTOTPSecret(ctx, targetID); err != nil { + return nil, humaError(err) + } + // После сброса второго фактора сессии отзываются: владелец входит + // заново паролем и настраивает новый ключ. + revoked, err := s.store.RevokeUserSessions(ctx, targetID) + if err != nil { + return nil, humaError(err) + } + if s.gateway != nil { + s.gateway.InvalidateUser(targetID, "2fa_reset") + } + s.recordSecurityEvent(ctx, &targetID, "2fa_change", session, map[string]any{ + "action": "reset", + "by": "instance_admin", + }) + s.recordAudit(ctx, admin, 0, "instance.user_2fa_reset", "user", &targetID, "") + output := &adminReset2FAOutput{} + output.Body.UserID = formatSnowflake(targetID) + output.Body.SessionsRevoked = revoked return output, nil }) } @@ -829,3 +1077,450 @@ func newTemporaryPassword() (string, error) { } return builder.String(), nil } + +// instanceAuditOutput — страница журнала инстанса с общим числом записей под +// фильтр: панели нужна пагинация (AGENT.md 7.18). +type instanceAuditOutput struct { + Body struct { + Entries []auditEntryPayload `json:"entries"` + Total int `json:"total"` + } +} + +type instanceAuditActionsOutput struct { + Body struct { + Actions []string `json:"actions"` + } +} + +// dailyCountPayload — точка роста для графика статистики. +type dailyCountPayload struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type instanceGuildStatPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Members int `json:"members"` + Messages int `json:"messages"` +} + +type instanceStatsPayload struct { + Users struct { + Total int `json:"total"` + Admins int `json:"admins"` + Banned int `json:"banned"` + New7Days int `json:"new_7_days"` + New30Days int `json:"new_30_days"` + Active24h int `json:"active_24h"` + Daily []dailyCountPayload `json:"daily"` + } `json:"users"` + Messages struct { + Total int `json:"total"` + Today int `json:"today"` + Week int `json:"week"` + Month int `json:"month"` + Daily []dailyCountPayload `json:"daily"` + } `json:"messages"` + Files struct { + Count int `json:"count"` + Bytes int64 `json:"bytes"` + } `json:"files"` + Guilds int `json:"guilds"` + Invites int `json:"invites"` + Bans int `json:"bans"` + DatabaseBytes int64 `json:"database_bytes"` + StorageBytes int64 `json:"storage_bytes"` + TopGuilds []instanceGuildStatPayload `json:"top_guilds"` + BusiestGuilds []instanceGuildStatPayload `json:"busiest_guilds"` +} + +type instanceStatsOutput struct { + Body struct { + Stats instanceStatsPayload `json:"stats"` + } +} + +type instanceSessionPayload struct { + ID string `json:"id"` + UserAgent string `json:"user_agent,omitempty"` + IP string `json:"ip,omitempty"` + CreatedAt string `json:"created_at"` + LastSeen string `json:"last_seen"` + ExpiresAt string `json:"expires_at"` + // SteppedUp — сессия недавно подтверждала пароль: видно, какие устройства + // администратор может отозвать без последствий для остальных. + SteppedUp bool `json:"stepped_up"` +} + +type instanceMembershipRolePayload struct { + ID string `json:"id"` + Name string `json:"name"` + Color int64 `json:"color"` +} + +type instanceMembershipPayload struct { + GuildID string `json:"guild_id"` + GuildName string `json:"guild_name"` + Nickname string `json:"nickname,omitempty"` + JoinedAt string `json:"joined_at"` + Roles []instanceMembershipRolePayload `json:"roles"` +} + +// instanceUserCardOutput — карточка пользователя: профиль, устройства, +// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18). +type instanceUserCardOutput struct { + Body struct { + User instanceUserPayload `json:"user"` + TOTPEnabled bool `json:"totp_enabled"` + Passkeys int `json:"passkeys"` + Sessions []instanceSessionPayload `json:"sessions"` + Guilds []instanceMembershipPayload `json:"guilds"` + SecurityEvents []securityEventPayload `json:"security_events"` + Audit []auditEntryPayload `json:"audit"` + AuditTotal int `json:"audit_total"` + } +} + +type adminReset2FAOutput struct { + Body struct { + UserID string `json:"user_id"` + SessionsRevoked int64 `json:"sessions_revoked"` + } +} + +// securityEventsLimit и userAuditLimit — сколько событий и записей аудита +// показывать в карточке пользователя: остальное доступно в разделе «Аудит». +const ( + securityEventsLimit = 20 + userAuditLimit = 20 +) + +// auditQueryParams — фильтры журнала, как они приходят из строки запроса. +// Один разбор используется и JSON-ручкой (huma), и экспортом CSV. +type auditQueryParams struct { + Action string + ActorID string + TargetID string + GuildID string + Since string + Until string + Limit int + Offset int +} + +// auditFilterFromQuery собирает фильтр журнала и разбирает даты (AGENT.md 7.10). +func auditFilterFromQuery(params auditQueryParams) (store.InstanceAuditFilter, error) { + filter := store.InstanceAuditFilter{ + Action: strings.TrimSpace(params.Action), + Limit: params.Limit, + Offset: params.Offset, + } + for _, item := range []struct { + raw string + name string + into *uint64 + }{ + {params.ActorID, "actor_id", &filter.ActorID}, + {params.TargetID, "target_id", &filter.TargetID}, + {params.GuildID, "guild_id", &filter.GuildID}, + } { + if strings.TrimSpace(item.raw) == "" { + continue + } + value, err := parseID(item.name, strings.TrimSpace(item.raw)) + if err != nil { + return store.InstanceAuditFilter{}, err + } + *item.into = value + } + since, err := parseAuditDate(params.Since, false) + if err != nil { + return store.InstanceAuditFilter{}, err + } + until, err := parseAuditDate(params.Until, true) + if err != nil { + return store.InstanceAuditFilter{}, err + } + filter.Since = since + filter.Until = until + return filter, nil +} + +// parseAuditDate читает дату фильтра в формате YYYY-MM-DD (UTC). Для «по дату» +// берётся конец дня: иначе запись за 20 сентября не попала бы в выборку +// «по 20 сентября» (время в базе хранится с точностью до миллисекунд). +func parseAuditDate(value string, endOfDay bool) (*time.Time, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + parsed, err := time.Parse("2006-01-02", trimmed) + if err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "date must be YYYY-MM-DD") + } + if endOfDay { + parsed = parsed.Add(24*time.Hour - time.Millisecond) + } + return &parsed, nil +} + +// instanceStatsPayload дополняет выборку из store размерами хранилища: размер +// базы и файлов виден только серверу (AGENT.md 7.18). +func (s *Server) instanceStatsPayload(stats *store.InstanceStats) instanceStatsPayload { + payload := instanceStatsPayload{ + Guilds: stats.Guilds, + Invites: stats.Invites, + Bans: stats.Bans, + } + payload.Users.Total = stats.Users.Total + payload.Users.Admins = stats.Users.Admins + payload.Users.Banned = stats.Users.Banned + payload.Users.New7Days = stats.Users.New7Days + payload.Users.New30Days = stats.Users.New30Days + payload.Users.Active24h = stats.Users.Active24h + payload.Users.Daily = dailyCountsPayload(stats.Users.Daily) + payload.Messages.Total = stats.Messages.Total + payload.Messages.Today = stats.Messages.Today + payload.Messages.Week = stats.Messages.Week + payload.Messages.Month = stats.Messages.Month + payload.Messages.Daily = dailyCountsPayload(stats.Messages.Daily) + payload.Files.Count = stats.Files.Count + payload.Files.Bytes = stats.Files.Bytes + payload.TopGuilds = guildStatsPayload(stats.TopGuilds) + payload.BusiestGuilds = guildStatsPayload(stats.BusiestGuilds) + database := sysinfo.FileBytes(s.databasePath()) + database += sysinfo.FileBytes(s.databasePath() + "-wal") + payload.DatabaseBytes = database + payload.StorageBytes = database + stats.Files.Bytes + return payload +} + +func dailyCountsPayload(counts []store.DailyCount) []dailyCountPayload { + payload := make([]dailyCountPayload, 0, len(counts)) + for _, item := range counts { + payload = append(payload, dailyCountPayload{Date: item.Date, Count: item.Count}) + } + return payload +} + +func guildStatsPayload(guilds []store.InstanceGuildStat) []instanceGuildStatPayload { + payload := make([]instanceGuildStatPayload, 0, len(guilds)) + for _, item := range guilds { + payload = append(payload, instanceGuildStatPayload{ + ID: formatSnowflake(item.ID), Name: item.Name, + Members: item.Members, Messages: item.Messages, + }) + } + return payload +} + +// instanceUserCard собирает карточку пользователя для админ-панели: профиль, +// активные устройства, серверы с ролями, события безопасности и аудит, где он +// актор или цель (AGENT.md 7.18, 7.19). +func (s *Server) instanceUserCard(ctx context.Context, target *store.User) (*instanceUserCardOutput, error) { + output := &instanceUserCardOutput{} + output.Body.User = instanceUserPayloadFrom(*target) + output.Body.Sessions = []instanceSessionPayload{} + output.Body.Guilds = []instanceMembershipPayload{} + output.Body.SecurityEvents = []securityEventPayload{} + + sessions, err := s.store.ListSessions(ctx, target.ID) + if err != nil { + return nil, humaError(err) + } + for _, session := range sessions { + output.Body.Sessions = append(output.Body.Sessions, instanceSessionPayload{ + ID: formatSnowflake(session.ID), + UserAgent: session.UserAgent, + IP: session.IP, + CreatedAt: session.CreatedAt.UTC().Format(time.RFC3339), + LastSeen: session.LastSeen.UTC().Format(time.RFC3339), + ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339), + SteppedUp: session.SteppedUp(time.Now().UTC()), + }) + } + + memberships, err := s.store.ListUserGuildMemberships(ctx, target.ID) + if err != nil { + return nil, humaError(err) + } + for _, membership := range memberships { + item := instanceMembershipPayload{ + GuildID: formatSnowflake(membership.GuildID), + GuildName: membership.GuildName, + Nickname: membership.Nickname, + JoinedAt: membership.JoinedAt.UTC().Format(time.RFC3339), + Roles: []instanceMembershipRolePayload{}, + } + for _, role := range membership.Roles { + item.Roles = append(item.Roles, instanceMembershipRolePayload{ + ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color, + }) + } + output.Body.Guilds = append(output.Body.Guilds, item) + } + + events, err := s.store.ListSecurityEvents(ctx, target.ID, securityEventsLimit) + if err != nil { + return nil, humaError(err) + } + for _, event := range events { + output.Body.SecurityEvents = append(output.Body.SecurityEvents, securityEventPayload{ + ID: formatSnowflake(event.ID), + Type: event.Type, + IP: event.IP, + UserAgent: event.UserAgent, + Metadata: event.Metadata, + CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339), + }) + } + + entries, total, err := s.store.ListUserAudit(ctx, target.ID, userAuditLimit, 0) + if err != nil { + return nil, humaError(err) + } + output.Body.Audit = auditPayloads(entries) + output.Body.AuditTotal = total + + passkeys, err := s.store.CountPasskeys(ctx, target.ID) + if err != nil { + return nil, humaError(err) + } + output.Body.Passkeys = passkeys + if secret, err := s.store.GetTOTPSecret(ctx, target.ID); err == nil { + output.Body.TOTPEnabled = secret.Enabled + } else if !errors.Is(err, store.ErrNotFound) { + return nil, humaError(err) + } + return output, nil +} + +// registerInstanceExportRoutes описывает выгрузку журнала инстанса: ответ — +// CSV, а не JSON, поэтому ручка живёт на роутере chi, а не в huma. +func (s *Server) registerInstanceExportRoutes(router chi.Router) { + router.Get("/instance/audit/export", s.handleInstanceAuditExport) +} + +// handleInstanceAuditExport отдаёт журнал инстанса файлом CSV с теми же +// фильтрами, что и раздел «Аудит» (AGENT.md 7.18). Предел выгрузки — 5000 +// строк, иначе один запрос вычитывал бы весь журнал в память. +func (s *Server) handleInstanceAuditExport(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + if !user.IsInstanceAdmin { + httpxWriteJSONError(w, http.StatusForbidden, "instance.admin_required", "instance administrator rights required") + return + } + if allowed, retryAfter := s.auditExportLimiter.Allow("audit-export:" + formatSnowflake(user.ID)); !allowed { + writeHumaAPIError(w, rateLimitedError(retryAfter)) + return + } + query := r.URL.Query() + filter, err := auditFilterFromQuery(auditQueryParams{ + Action: query.Get("action"), + ActorID: query.Get("actor_id"), + TargetID: query.Get("target_id"), + GuildID: query.Get("guild_id"), + Since: query.Get("since"), + Until: query.Get("until"), + Limit: store.MaxInstanceAuditLimit, + }) + if err != nil { + writeHumaAPIError(w, err) + return + } + entries, _, err := s.store.ListInstanceAuditFiltered(r.Context(), filter) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", + `attachment; filename="audit-`+time.Now().UTC().Format("20060102-150405")+`.csv"`) + w.Header().Set("X-Content-Type-Options", "nosniff") + + writer := csv.NewWriter(w) + write := func(row []string) { + safe := make([]string, 0, len(row)) + for _, value := range row { + safe = append(safe, csvSafe(value)) + } + _ = writer.Write(safe) + } + write([]string{ + "id", "created_at", "action", "actor_id", "actor_instance_admin", + "target_type", "target_id", "guild_id", "reason", "changes", + }) + for _, entry := range entries { + write([]string{ + formatSnowflake(entry.ID), + entry.CreatedAt.UTC().Format(time.RFC3339), + entry.Action, + optionalSnowflake(entry.ActorID), + strconv.FormatBool(entry.ActorInstanceAdmin), + entry.TargetType, + optionalSnowflake(entry.TargetID), + optionalSnowflake(entry.GuildID), + entry.Reason, + string(entry.Changes), + }) + } + writer.Flush() + if err := writer.Error(); err != nil { + s.logger.WarnContext(r.Context(), "audit export failed", slog.Any("error", err)) + } + s.recordAudit(r.Context(), user, 0, "instance.audit_export", "instance", nil, "") +} + +// csvSafe защищает выгрузку от формул Excel: значения, начинающиеся с «=», +// «+», «-» или «@», таблица воспринимает как формулу (CSV injection). +func csvSafe(value string) string { + if value == "" { + return value + } + switch value[0] { + case '=', '+', '-', '@': + return "'" + value + default: + return value + } +} + +// optionalSnowflake печатает необязательный идентификатор. +func optionalSnowflake(id *uint64) string { + if id == nil { + return "" + } + return formatSnowflake(*id) +} + +// recordSecurityEvent пишет событие безопасности по действию администратора +// инстанса: владелец аккаунта должен видеть в разделе безопасности, что его +// устройство отозвали или сбросили второй фактор (AGENT.md 7.1, 7.19). Ошибка +// записи не ломает основное действие. +func (s *Server) recordSecurityEvent( + ctx context.Context, + userID *uint64, + eventType string, + session *store.Session, + metadata map[string]any, +) { + encoded := "{}" + if len(metadata) > 0 { + if raw, err := json.Marshal(metadata); err == nil { + encoded = string(raw) + } + } + var ip, userAgent string + if session != nil { + ip, userAgent = session.IP, session.UserAgent + } + if err := s.store.RecordSecurityEvent(ctx, userID, eventType, ip, userAgent, encoded); err != nil { + s.logger.WarnContext(ctx, "failed to record security event", slog.Any("error", err)) + } +} diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 60afa53..9dc4e0a 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "log/slog" "net/http" "strings" @@ -147,13 +148,15 @@ type onboardingInput struct { } } -// securityEventPayload — событие безопасности без служебных метаданных. +// securityEventPayload — событие безопасности владельца аккаунта; карточка +// пользователя в админ-панели дополняет его метаданными (AGENT.md 7.18). type securityEventPayload struct { - ID string `json:"id"` - Type string `json:"type"` - IP string `json:"ip,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Type string `json:"type"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt string `json:"created_at"` + Metadata json.RawMessage `json:"metadata,omitempty"` } type securityEventListOutput struct { diff --git a/internal/server/instance_panel_test.go b/internal/server/instance_panel_test.go new file mode 100644 index 0000000..3447ab9 --- /dev/null +++ b/internal/server/instance_panel_test.go @@ -0,0 +1,583 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Тесты расширенной админ-панели инстанса (AGENT.md 3.2, 7.18, 11.5): +// обычный пользователь не видит разделы, администратор получает данные, +// чувствительные действия требуют step-up и попадают в аудит. + +// instanceGet вызывает ручку панели администратором и проверяет статус. +func instanceGet(t *testing.T, srv *Server, cookie *http.Cookie, path string, want int) *httptest.ResponseRecorder { + t.Helper() + rec := doJSON(t, srv, http.MethodGet, path, "", cookie) + if rec.Code != want { + t.Fatalf("GET %s = %d, want %d (body: %s)", path, rec.Code, want, rec.Body.String()) + } + return rec +} + +// TestInstancePanelRequiresAdmin: разделы расширенной панели закрыты для +// обычного пользователя (AGENT.md 7.18, 11.5). +func TestInstancePanelRequiresAdmin(t *testing.T) { + srv, _ := newTestServer(t) + userCookie := registerAndLogin(t, srv, "panel_user", "panel-user@example.com") + target, err := srv.auth.UserByEmail(t.Context(), "panel-user@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + targetPath := formatSnowflake(target.ID) + + for _, path := range []string{ + "/api/v1/instance/stats", + "/api/v1/instance/audit", + "/api/v1/instance/audit/actions", + "/api/v1/instance/audit/export", + "/api/v1/instance/users/" + targetPath, + "/api/v1/instance/guilds?limit=10", + } { + rec := doJSON(t, srv, http.MethodGet, path, "", userCookie) + if rec.Code != http.StatusForbidden { + t.Fatalf("GET %s обычным пользователем = %d, want 403", path, rec.Code) + } + } +} + +// TestInstanceStatsVisibility: статистика инстанса считает пользователей, +// сообщения и серверы, а обычному пользователю недоступна (AGENT.md 7.18). +func TestInstanceStatsVisibility(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "stats_admin", "stats-admin@example.com") + promoteAdmin(t, srv, "stats-admin@example.com") + registerAndLogin(t, srv, "stats_plain", "stats-plain@example.com") + + rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/stats", http.StatusOK) + stats := decodeResponse[struct { + Stats struct { + Users struct { + Total int `json:"total"` + Admins int `json:"admins"` + New7Days int `json:"new_7_days"` + Daily []struct { + Date string `json:"date"` + Count int `json:"count"` + } `json:"daily"` + } `json:"users"` + Messages struct { + Total int `json:"total"` + Today int `json:"today"` + } `json:"messages"` + Guilds int `json:"guilds"` + DatabaseBytes int64 `json:"database_bytes"` + StorageBytes int64 `json:"storage_bytes"` + } `json:"stats"` + }](t, rec) + if stats.Stats.Users.Total < 2 || stats.Stats.Users.New7Days < 2 { + t.Fatalf("пользователи в статистике: %+v", stats.Stats.Users) + } + if stats.Stats.Users.Admins != 1 { + t.Fatalf("администраторов = %d, want 1", stats.Stats.Users.Admins) + } + if len(stats.Stats.Users.Daily) == 0 { + t.Fatal("рост пользователей по дням пуст") + } + if stats.Stats.DatabaseBytes <= 0 || stats.Stats.StorageBytes < stats.Stats.DatabaseBytes { + t.Fatalf("размеры данных: %+v", stats.Stats) + } +} + +// TestInstanceAuditFiltersAndExport: фильтры журнала, пагинация и выгрузка CSV +// (AGENT.md 7.10, 7.18). +func TestInstanceAuditFiltersAndExport(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "audit_admin", "audit-admin@example.com") + promoteAdmin(t, srv, "audit-admin@example.com") + registerAndLogin(t, srv, "audit_target", "audit-target@example.com") + target, err := srv.auth.UserByEmail(t.Context(), "audit-target@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + targetID := formatSnowflake(target.ID) + + // Действия администратора создают записи журнала. + if rec := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+targetID+"/logout", "", adminCookie); rec.Code != http.StatusOK { + t.Fatalf("logout = %d (%s)", rec.Code, rec.Body.String()) + } + if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", + `{"max_message_length":4001}`, adminCookie); rec.Code != http.StatusOK { + t.Fatalf("settings = %d (%s)", rec.Code, rec.Body.String()) + } + + listAudit := func(query string) ([]string, int) { + t.Helper() + path := "/api/v1/instance/audit" + if query != "" { + path += "?" + query + } + rec := instanceGet(t, srv, adminCookie, path, http.StatusOK) + payload := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + TargetID string `json:"target_id"` + } `json:"entries"` + Total int `json:"total"` + }](t, rec) + actions := make([]string, 0, len(payload.Entries)) + for _, entry := range payload.Entries { + actions = append(actions, entry.Action) + } + return actions, payload.Total + } + + all, total := listAudit("") + if total < 2 || len(all) < 2 { + t.Fatalf("журнал пуст: %v (total %d)", all, total) + } + byAction, total := listAudit("action=instance.user_logout") + if total != 1 || len(byAction) != 1 || byAction[0] != "instance.user_logout" { + t.Fatalf("фильтр по действию: %v (total %d)", byAction, total) + } + byTarget, total := listAudit("target_id=" + targetID) + if total != 1 || len(byTarget) != 1 { + t.Fatalf("фильтр по цели: %v (total %d)", byTarget, total) + } + actorID := "" + { + admin, err := srv.auth.UserByEmail(t.Context(), "audit-admin@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + actorID = formatSnowflake(admin.ID) + } + byActor, total := listAudit("actor_id=" + actorID) + if total < 2 || len(byActor) < 2 { + t.Fatalf("фильтр по актору: %v (total %d)", byActor, total) + } + today := time.Now().UTC().Format("2006-01-02") + byDate, total := listAudit("since=" + today + "&until=" + today) + if total < 2 { + t.Fatalf("фильтр по дате: %v (total %d)", byDate, total) + } + if _, total := listAudit("since=2030-01-01"); total != 0 { + t.Fatalf("фильтр «в будущем» вернул %d записей", total) + } + page1, _ := listAudit("limit=1&offset=0") + page2, _ := listAudit("limit=1&offset=1") + if len(page1) != 1 || len(page2) != 1 || page1[0] == page2[0] { + t.Fatalf("пагинация журнала: %v / %v", page1, page2) + } + + // Неверная дата — понятная ошибка валидации, а не пустой журнал. + bad := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit?since=вчера", "", adminCookie) + if bad.Code != http.StatusUnprocessableEntity { + t.Fatalf("неверная дата = %d, want 422", bad.Code) + } + + // Список действий для фильтра. + actionsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit/actions", http.StatusOK) + actions := decodeResponse[struct { + Actions []string `json:"actions"` + }](t, actionsRec) + foundLogout := false + for _, action := range actions.Actions { + if action == "instance.user_logout" { + foundLogout = true + } + } + if !foundLogout { + t.Fatalf("список действий не содержит instance.user_logout: %v", actions.Actions) + } + + // Экспорт CSV: тот же фильтр, заголовок и строки. + export := doJSON(t, srv, http.MethodGet, + "/api/v1/instance/audit/export?action=instance.user_logout", "", adminCookie) + if export.Code != http.StatusOK { + t.Fatalf("экспорт = %d (%s)", export.Code, export.Body.String()) + } + if contentType := export.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") { + t.Fatalf("Content-Type экспорта = %q", contentType) + } + body := export.Body.String() + if !strings.Contains(body, "id,created_at,action") || !strings.Contains(body, "instance.user_logout") { + t.Fatalf("тело экспорта: %s", body) + } + if strings.Contains(body, "instance.settings_update") { + t.Fatal("экспорт не учёл фильтр по действию") + } + // Экспорт — тоже событие журнала. + _, total = listAudit("action=instance.audit_export") + if total != 1 { + t.Fatalf("экспорт не записан в аудит: total %d", total) + } +} + +// TestInstanceUserCardShowsDetails: карточка пользователя отдаёт устройства, +// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18). +func TestInstanceUserCardShowsDetails(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "card_admin", "card-admin@example.com") + promoteAdmin(t, srv, "card-admin@example.com") + targetCookie := registerAndLogin(t, srv, "card_target", "card-target@example.com") + target, err := srv.auth.UserByEmail(t.Context(), "card-target@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + targetID := formatSnowflake(target.ID) + + // У цели есть сервер (создаётся вместе с ролями по умолчанию) — карточка + // должна показать участие и роли. + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Карточка"}`, targetCookie) + if created.Code != http.StatusOK { + t.Fatalf("создать сервер = %d (%s)", created.Code, created.Body.String()) + } + + rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) + card := decodeResponse[struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"user"` + TOTPEnabled bool `json:"totp_enabled"` + Passkeys int `json:"passkeys"` + Sessions []struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + } `json:"sessions"` + Guilds []struct { + GuildName string `json:"guild_name"` + Roles []struct { + Name string `json:"name"` + } `json:"roles"` + } `json:"guilds"` + SecurityEvents []struct { + Type string `json:"type"` + } `json:"security_events"` + Audit []any `json:"audit"` + AuditTotal int `json:"audit_total"` + }](t, rec) + if card.User.ID != targetID || card.User.Username != "card_target" { + t.Fatalf("профиль в карточке: %+v", card.User) + } + if len(card.Sessions) != 1 || card.Sessions[0].ID == "" { + t.Fatalf("устройства в карточке: %+v", card.Sessions) + } + if len(card.SecurityEvents) == 0 { + t.Fatal("события безопасности не попали в карточку") + } + if card.TOTPEnabled || card.Passkeys != 0 { + t.Fatalf("второй фактор/ключи: %v/%d", card.TOTPEnabled, card.Passkeys) + } + if len(card.Guilds) != 1 || card.Guilds[0].GuildName != "Карточка" { + t.Fatalf("серверы в карточке: %+v", card.Guilds) + } + if len(card.Guilds[0].Roles) == 0 { + t.Fatalf("роли в карточке пусты: %+v", card.Guilds[0]) + } + + // Несуществующий пользователь — 404, а не пустая карточка. + missing := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users/999999999", "", adminCookie) + if missing.Code != http.StatusNotFound { + t.Fatalf("карточка несуществующего = %d, want 404", missing.Code) + } +} + +// TestInstanceRevokeSession: отзыв одного устройства требует step-up, попадает +// в аудит и в события безопасности, второе устройство продолжает работать +// (AGENT.md 7.1, 7.18, 11.6). +func TestInstanceRevokeSession(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "revoke_admin", "revoke-admin@example.com") + promoteAdmin(t, srv, "revoke-admin@example.com") + firstCookie := registerAndLogin(t, srv, "revoke_target", "revoke-target@example.com") + // Второе устройство: обычный вход тем же паролем. + second := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", + `{"email":"revoke-target@example.com","password":"correct-horse-battery"}`) + if second.Code != http.StatusOK { + t.Fatalf("второй вход = %d (%s)", second.Code, second.Body.String()) + } + secondCookie := second.Result().Cookies()[0] + target, err := srv.auth.UserByEmail(t.Context(), "revoke-target@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + targetID := formatSnowflake(target.ID) + + cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) + card := decodeResponse[struct { + Sessions []struct { + ID string `json:"id"` + } `json:"sessions"` + }](t, cardRec) + if len(card.Sessions) != 2 { + t.Fatalf("устройств у цели = %d, want 2", len(card.Sessions)) + } + + // Определяем, какая из сессий принадлежит второму устройству: отзываем + // первую и проверяем, что хотя бы одно устройство продолжает работать. + revokePath := "/api/v1/instance/users/" + targetID + "/sessions/" + card.Sessions[0].ID + noStepUp := doJSON(t, srv, http.MethodDelete, revokePath, `{}`, adminCookie) + if noStepUp.Code != http.StatusForbidden { + t.Fatalf("отзыв без step-up = %d, want 403", noStepUp.Code) + } + if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { + t.Fatalf("код отказа без step-up = %q", code) + } + + ok := doJSON(t, srv, http.MethodDelete, revokePath, + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if ok.Code != http.StatusOK { + t.Fatalf("отзыв устройства = %d (%s)", ok.Code, ok.Body.String()) + } + + // Отозванное устройство теряет доступ, второе продолжает работать. + alive := 0 + for _, cookie := range []*http.Cookie{firstCookie, secondCookie} { + rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) + if rec.Code == http.StatusOK { + alive++ + } + } + if alive != 1 { + t.Fatalf("после отзыва работает устройств: %d, want 1", alive) + } + + // Повторный отзыв той же сессии — 404. + again := doJSON(t, srv, http.MethodDelete, revokePath, + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if again.Code != http.StatusNotFound { + t.Fatalf("повторный отзыв = %d, want 404", again.Code) + } + + // Аудит и событие безопасности записаны. + auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_session_revoke", http.StatusOK) + audit := decodeResponse[struct { + Total int `json:"total"` + }](t, auditRec) + if audit.Total != 1 { + t.Fatalf("записей аудита об отзыве = %d, want 1", audit.Total) + } + cardRec = instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) + card = decodeResponse[struct { + Sessions []struct { + ID string `json:"id"` + } `json:"sessions"` + }](t, cardRec) + if len(card.Sessions) != 1 { + t.Fatalf("устройств после отзыва = %d, want 1", len(card.Sessions)) + } + eventsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) + events := decodeResponse[struct { + SecurityEvents []struct { + Type string `json:"type"` + Metadata struct { + By string `json:"by"` + } `json:"metadata"` + } `json:"security_events"` + }](t, eventsRec) + found := false + for _, event := range events.SecurityEvents { + if event.Type == "session_revoked" && event.Metadata.By == "instance_admin" { + found = true + } + } + if !found { + t.Fatalf("событие session_revoked не записано: %+v", events.SecurityEvents) + } +} + +// TestInstanceReset2FA: сброс второго фактора требует step-up, защищает +// администраторов и записывается в аудит и события безопасности (AGENT.md 7.18). +func TestInstanceReset2FA(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "tfa_admin", "tfa-admin@example.com") + promoteAdmin(t, srv, "tfa-admin@example.com") + registerAndLogin(t, srv, "tfa_target", "tfa-target@example.com") + target, err := srv.auth.UserByEmail(t.Context(), "tfa-target@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + targetID := formatSnowflake(target.ID) + resetPath := "/api/v1/instance/users/" + targetID + "/reset-2fa" + + // Без step-up сброс отклоняется: проверка свежести аутентификации идёт до + // проверок цели (AGENT.md 7.1). + noStepUp := doJSON(t, srv, http.MethodPost, resetPath, `{}`, adminCookie) + if noStepUp.Code != http.StatusForbidden { + t.Fatalf("сброс без step-up = %d, want 403", noStepUp.Code) + } + if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { + t.Fatalf("код отказа без step-up = %q", code) + } + + // С подтверждением, но без включённого второго фактора сбрасывать нечего. + empty := doJSON(t, srv, http.MethodPost, resetPath, + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if empty.Code != http.StatusUnprocessableEntity { + t.Fatalf("сброс без 2FA = %d, want 422", empty.Code) + } + + // Включаем второй фактор напрямую в хранилище (как это делает раздел + // безопасности) и сбрасываем его администратором. + if err := srv.store.UpsertTOTPSecret(t.Context(), target.ID, "encrypted-secret"); err != nil { + t.Fatalf("записать секрет: %v", err) + } + if err := srv.store.EnableTOTP(t.Context(), target.ID, nil); err != nil { + t.Fatalf("включить 2FA: %v", err) + } + ok := doJSON(t, srv, http.MethodPost, resetPath, + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if ok.Code != http.StatusOK { + t.Fatalf("сброс 2FA = %d (%s)", ok.Code, ok.Body.String()) + } + secret, err := srv.store.GetTOTPSecret(t.Context(), target.ID) + if err == nil && secret.Enabled { + t.Fatal("второй фактор остался включённым после сброса") + } + + // Аудит и событие безопасности. + auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_2fa_reset", http.StatusOK) + audit := decodeResponse[struct { + Total int `json:"total"` + }](t, auditRec) + if audit.Total != 1 { + t.Fatalf("записей аудита о сбросе 2FA = %d, want 1", audit.Total) + } + cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) + card := decodeResponse[struct { + TOTPEnabled bool `json:"totp_enabled"` + SecurityEvents []struct { + Type string `json:"type"` + } `json:"security_events"` + }](t, cardRec) + if card.TOTPEnabled { + t.Fatal("карточка всё ещё показывает включённый второй фактор") + } + foundEvent := false + for _, event := range card.SecurityEvents { + if event.Type == "2fa_change" { + foundEvent = true + } + } + if !foundEvent { + t.Fatalf("событие 2fa_change не записано: %+v", card.SecurityEvents) + } + + // Себя сбрасывать нельзя: свой второй фактор меняют в настройках. + self := doJSON(t, srv, http.MethodPost, + "/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin@example.com")+"/reset-2fa", + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if self.Code != http.StatusUnprocessableEntity { + t.Fatalf("сброс себе = %d, want 422", self.Code) + } + // Другого администратора — тоже: иначе сброс чужого ключа обходил бы + // обязательный второй фактор инстанс-админа (AGENT.md 7.19). + registerAndLogin(t, srv, "tfa_admin2", "tfa-admin2@example.com") + promoteAdmin(t, srv, "tfa-admin2@example.com") + protected := doJSON(t, srv, http.MethodPost, + "/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin2@example.com")+"/reset-2fa", + `{"step_up_password":"correct-horse-battery"}`, adminCookie) + if protected.Code != http.StatusForbidden { + t.Fatalf("сброс 2FA другого администратора = %d, want 403", protected.Code) + } +} + +// adminIDOf возвращает snowflake-идентификатор пользователя по email. +func adminIDOf(t *testing.T, srv *Server, email string) string { + t.Helper() + user, err := srv.auth.UserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("UserByEmail(%s): %v", email, err) + } + return formatSnowflake(user.ID) +} + +// TestInstanceGuildListFilters: список серверов панели ищет по названию без +// учёта регистра, фильтрует по владельцу и листается (AGENT.md 7.18). +func TestInstanceGuildListFilters(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "list_admin", "list-admin@example.com") + promoteAdmin(t, srv, "list-admin@example.com") + ownerCookie := registerAndLogin(t, srv, "list_owner", "list-owner@example.com") + ownerID := adminIDOf(t, srv, "list-owner@example.com") + + for _, name := range []string{"Альфа", "Бета"} { + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, ownerCookie) + if rec.Code != http.StatusOK { + t.Fatalf("создать сервер %s = %d (%s)", name, rec.Code, rec.Body.String()) + } + } + + type guildList struct { + Guilds []struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + OwnerName string `json:"owner_name"` + MemberCount int `json:"member_count"` + } `json:"guilds"` + Total int `json:"total"` + } + + allRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=200", http.StatusOK) + list := decodeResponse[guildList](t, allRec) + if list.Total < 2 || len(list.Guilds) < 2 { + t.Fatalf("список серверов: %+v (total %d)", list.Guilds, list.Total) + } + found := false + for _, guild := range list.Guilds { + if guild.Name == "Альфа" { + found = true + if guild.OwnerName != "list_owner" || guild.MemberCount != 1 { + t.Fatalf("строка сервера: %+v", guild) + } + } + } + if !found { + t.Fatal("сервер «Альфа» не найден") + } + + // Поиск без учёта регистра: кириллица в SQLite lower() не приводится, + // поэтому сравнивается нормализованная копия названия. + searchRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK) + search := decodeResponse[guildList](t, searchRec) + if search.Total != 1 || len(search.Guilds) != 1 || search.Guilds[0].Name != "Альфа" { + t.Fatalf("поиск по названию: %+v (total %d)", search.Guilds, search.Total) + } + + ownerRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?owner_id="+ownerID, http.StatusOK) + byOwner := decodeResponse[guildList](t, ownerRec) + if byOwner.Total != 2 { + t.Fatalf("фильтр по владельцу: total %d, want 2", byOwner.Total) + } + + pageRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=1&offset=1", http.StatusOK) + page := decodeResponse[guildList](t, pageRec) + if len(page.Guilds) != 1 || page.Total != byOwner.Total { + t.Fatalf("пагинация серверов: %+v (total %d)", page.Guilds, page.Total) + } + + // Переименование обновляет нормализованное название: старый поиск пуст. + if _, adminErr := srv.auth.UserByEmail(t.Context(), "list-admin@example.com"); adminErr != nil { + t.Fatalf("UserByEmail: %v", adminErr) + } + renameRec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+search.Guilds[0].ID, + `{"name":"Гамма"}`, adminCookie) + if renameRec.Code != http.StatusOK { + t.Fatalf("переименование = %d (%s)", renameRec.Code, renameRec.Body.String()) + } + oldRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK) + old := decodeResponse[guildList](t, oldRec) + if old.Total != 0 { + t.Fatalf("после переименования старый поиск вернул %d записей", old.Total) + } + newRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=гамма", http.StatusOK) + renamed := decodeResponse[guildList](t, newRec) + if renamed.Total != 1 { + t.Fatalf("поиск по новому названию: total %d, want 1", renamed.Total) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 630d3de..b9dc871 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -97,6 +97,9 @@ type Server struct { hostCPUSampler *sysinfo.Sampler // metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду). metricsLimiter *httpx.RateLimiter + // auditExportLimiter — выгрузка журнала инстанса: тяжёлый ответ, поэтому + // 5 экспортов в минуту на администратора (AGENT.md 7.18, 8.6). + auditExportLimiter *httpx.RateLimiter // health — кэш тяжёлых проверок инстанса. health healthCache // presence — время последнего обновления last_seen по пользователю. @@ -153,9 +156,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se webhookSeen: map[string]time.Time{}, // Дашборд опрашивает метрики раз в секунду: 120/мин с запасом. metricsLimiter: httpx.NewRateLimiter(120, 30), - sysinfoPaths: sysinfo.DefaultPaths(), - voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), - voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL), + // Экспорт журнала — редкое и тяжёлое действие: 5 раз в минуту. + auditExportLimiter: httpx.NewRateLimiterWindow(5, time.Minute, 5), + sysinfoPaths: sysinfo.DefaultPaths(), + voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), + voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL), } s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths) s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths) @@ -200,6 +205,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerUserRoutes(s.api) s.registerGuildRoutes(s.api) s.registerInstanceRoutes(s.api) + s.registerInstanceExportRoutes(apiRouter) s.registerBanRoutes(s.api) s.registerMetricsRoutes(s.api) s.registerMessageRoutes(s.api) diff --git a/internal/store/guilds.go b/internal/store/guilds.go index c1d4943..a5dafcc 100644 --- a/internal/store/guilds.go +++ b/internal/store/guilds.go @@ -59,10 +59,11 @@ func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Gui } } _, err := s.writer.ExecContext(ctx, ` - INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, name_lower, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor, - boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, s.Now()) + boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, + strings.ToLower(params.Name), s.Now()) if err != nil { return nil, err } @@ -107,8 +108,8 @@ func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildPa args []any ) if params.Name != nil { - sets = append(sets, "name = ?") - args = append(args, *params.Name) + sets = append(sets, "name = ?", "name_lower = ?") + args = append(args, *params.Name, strings.ToLower(*params.Name)) } if params.Description != nil { sets = append(sets, "description = ?") @@ -179,6 +180,134 @@ func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) { return collectGuilds(rows) } +// BackfillGuildNameLower заполняет нормализованное название у серверов, +// созданных до появления колонки: поиск в админ-панели инстанса не должен +// зависеть от регистра (AGENT.md 7.18). Вызывается один раз при старте, +// порциями — как BackfillSearchIndex у сообщений. +func (s *Store) BackfillGuildNameLower(ctx context.Context, limit int) (int, error) { + if limit <= 0 || limit > 5000 { + limit = 500 + } + type item struct { + id uint64 + name string + } + // Читаем порцию и закрываем строки до записи: соединение чтения не должно + // держать курсор, пока идёт обновление. + items, err := func() ([]item, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT id, name FROM guilds WHERE name_lower = '' AND name <> '' LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + collected := make([]item, 0, limit) + for rows.Next() { + var entry item + if err := rows.Scan(&entry.id, &entry.name); err != nil { + return nil, err + } + collected = append(collected, entry) + } + return collected, rows.Err() + }() + if err != nil { + return 0, err + } + + updated := 0 + for _, entry := range items { + if _, err := s.writer.ExecContext(ctx, + `UPDATE guilds SET name_lower = ? WHERE id = ?`, + strings.ToLower(entry.name), int64(entry.id)); err != nil { + return updated, err + } + updated++ + } + return updated, nil +} + +// InstanceGuildRow — строка списка серверов инстанса: сам сервер, имя +// владельца и число участников одним запросом (AGENT.md 7.18). +type InstanceGuildRow struct { + Guild + OwnerName string + MemberCount int +} + +// InstanceGuildFilter — фильтры списка серверов инстанса: поиск по названию, +// владелец, только главный сервер и страница выдачи. +type InstanceGuildFilter struct { + Query string + OwnerID uint64 + MainOnly bool + Limit int + Offset int +} + +// ListInstanceGuilds отдаёт страницу серверов вместе с общим числом под тот же +// фильтр: панели нужны и поиск, и пагинация, а прежний список отдавал сразу +// все серверы инстанса (AGENT.md 7.18). +func (s *Store) ListInstanceGuilds(ctx context.Context, filter InstanceGuildFilter) ([]InstanceGuildRow, int, error) { + if filter.Limit <= 0 { + filter.Limit = 50 + } + if filter.Limit > 200 { + filter.Limit = 200 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + clauses := []string{"1 = 1"} + args := make([]any, 0, 4) + if trimmed := strings.TrimSpace(filter.Query); trimmed != "" { + // Сравниваем с нормализованной копией названия: SQLite LIKE не + // приводит регистр кириллицы (AGENT.md 7.18). + clauses = append(clauses, "g.name_lower LIKE ?") + args = append(args, "%"+strings.ToLower(trimmed)+"%") + } + if filter.OwnerID != 0 { + clauses = append(clauses, "g.owner_id = ?") + args = append(args, mustID(filter.OwnerID)) + } + if filter.MainOnly { + clauses = append(clauses, "g.is_main = 1") + } + where := strings.Join(clauses, " AND ") + + var total int + if err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guilds g WHERE `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + queryArgs := make([]any, 0, len(args)+2) + queryArgs = append(queryArgs, args...) + queryArgs = append(queryArgs, filter.Limit, filter.Offset) + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+prefixColumns("g", guildColumns)+`, + COALESCE(u.username, ''), + (SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) + FROM guilds g LEFT JOIN users u ON u.id = g.owner_id + WHERE `+where+` ORDER BY g.id LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + guilds := make([]InstanceGuildRow, 0, filter.Limit) + for rows.Next() { + var row InstanceGuildRow + guild, err := scanGuild(rows, &row.OwnerName, &row.MemberCount) + if err != nil { + return nil, 0, err + } + row.Guild = *guild + guilds = append(guilds, row) + } + return guilds, total, rows.Err() +} + func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) { var count int err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count) @@ -309,7 +438,7 @@ func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID return nil } -func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) { +func scanGuild(scanner interface{ Scan(...any) error }, extra ...any) (*Guild, error) { var ( guild Guild iconID sql.NullInt64 @@ -320,8 +449,15 @@ func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) { limitsJSON string createdAt string ) - err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID, - &guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt) + // extra — дополнительные колонки запроса (например, имя владельца и число + // участников в списке инстанса): порядок сканирования остаётся прежним. + dest := make([]any, 0, 12+len(extra)) + dest = append(dest, + &guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID, + &guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt, + ) + dest = append(dest, extra...) + err := scanner.Scan(dest...) if err != nil { return nil, mapError(err) } diff --git a/internal/store/instance.go b/internal/store/instance.go index 9364203..4aaa84c 100644 --- a/internal/store/instance.go +++ b/internal/store/instance.go @@ -6,6 +6,7 @@ import ( "encoding/json" "math" "strconv" + "strings" "time" ) @@ -216,18 +217,149 @@ func parseUint(value string) (uint64, bool) { return result, true } -// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов -// инстанса (в том числе внутри серверов) и записи без привязки к серверу, -// которые переживают удаление сервера (AGENT.md 7.18). -func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) { - if limit <= 0 || limit > 200 { - limit = 50 +// MaxInstanceAuditLimit — предел выдачи журнала инстанса за один запрос: +// панель листает страницы по 50–200 строк, экспорт CSV берёт до 5000 +// (AGENT.md 7.10, 7.18). Больше не отдаём: выгрузка всего журнала одним +// ответом съедала бы память на 1 vCPU / 2 GB. +const MaxInstanceAuditLimit = 5000 + +// InstanceAuditFilter — условия выборки журнала инстанса (AGENT.md 7.10): +// действие, актор, цель, сервер и период. Незаполненные поля не ограничивают +// выборку, поэтому один тип используется и панелью, и экспортом. +type InstanceAuditFilter struct { + Action string + ActorID uint64 + TargetID uint64 + GuildID uint64 + Since *time.Time + Until *time.Time + Limit int + Offset int +} + +// instanceAuditConditions собирает WHERE для журнала инстанса: базовая область +// — действия администраторов инстанса (в том числе внутри серверов) и записи +// без привязки к серверу, которые переживают удаление сервера (AGENT.md 7.18). +// Время сравнивается строками: в базе лежит ISO 8601 UTC с миллисекундами, +// поэтому лексикографический порядок совпадает с хронологическим. +func (s *Store) instanceAuditConditions(filter InstanceAuditFilter) (string, []any) { + clauses := []string{"(actor_instance_admin = 1 OR guild_id IS NULL)"} + args := make([]any, 0, 6) + if filter.Action != "" { + clauses = append(clauses, "action = ?") + args = append(args, filter.Action) } + if filter.ActorID != 0 { + clauses = append(clauses, "actor_id = ?") + args = append(args, mustID(filter.ActorID)) + } + if filter.TargetID != 0 { + clauses = append(clauses, "target_id = ?") + args = append(args, mustID(filter.TargetID)) + } + if filter.GuildID != 0 { + clauses = append(clauses, "guild_id = ?") + args = append(args, mustID(filter.GuildID)) + } + if filter.Since != nil { + clauses = append(clauses, "created_at >= ?") + args = append(args, s.Timestamp(*filter.Since)) + } + if filter.Until != nil { + clauses = append(clauses, "created_at <= ?") + args = append(args, s.Timestamp(*filter.Until)) + } + return strings.Join(clauses, " AND "), args +} + +// ListInstanceAuditFiltered отдаёт страницу журнала инстанса вместе с общим +// числом записей под тот же фильтр: панели нужна пагинация, а не только +// первые N строк (AGENT.md 7.18). +func (s *Store) ListInstanceAuditFiltered(ctx context.Context, filter InstanceAuditFilter) ([]AuditEntry, int, error) { + if filter.Limit <= 0 { + filter.Limit = 50 + } + if filter.Limit > MaxInstanceAuditLimit { + filter.Limit = MaxInstanceAuditLimit + } + if filter.Offset < 0 { + filter.Offset = 0 + } + where, args := s.instanceAuditConditions(filter) + var total int + if err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM audit_log WHERE `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + queryArgs := make([]any, 0, len(args)+2) + queryArgs = append(queryArgs, args...) + queryArgs = append(queryArgs, filter.Limit, filter.Offset) rows, err := s.reader.QueryContext(ctx, ` SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at - FROM audit_log WHERE actor_instance_admin = 1 OR guild_id IS NULL ORDER BY id DESC LIMIT ?`, limit) + FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + return nil, 0, err + } + entries, err := scanAuditEntries(rows, filter.Limit) + if err != nil { + return nil, 0, err + } + return entries, total, nil +} + +// ListInstanceAuditActions перечисляет действия, которые встречаются в журнале +// инстанса: панель предлагает их списком, чтобы фильтр не набирали руками. +func (s *Store) ListInstanceAuditActions(ctx context.Context) ([]string, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT DISTINCT action FROM audit_log + WHERE actor_instance_admin = 1 OR guild_id IS NULL + ORDER BY action`) if err != nil { return nil, err } - return scanAuditEntries(rows, limit) + defer rows.Close() + + actions := make([]string, 0, 16) + for rows.Next() { + var action string + if err := rows.Scan(&action); err != nil { + return nil, err + } + actions = append(actions, action) + } + return actions, rows.Err() +} + +// ListUserAudit — история аудита по конкретному пользователю для его карточки: +// записи, где он цель (target_type = user) или актор (AGENT.md 7.18). Личные +// беседы в аудит не попадают вовсе, поэтому приватность переписки не +// нарушается даже полной выборкой по актору. +func (s *Store) ListUserAudit(ctx context.Context, userID uint64, limit, offset int) ([]AuditEntry, int, error) { + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + const where = "(target_type = 'user' AND target_id = ?) OR actor_id = ?" + var total int + if err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM audit_log WHERE `+where, mustID(userID), mustID(userID)).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at + FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, + mustID(userID), mustID(userID), limit, offset) + if err != nil { + return nil, 0, err + } + entries, err := scanAuditEntries(rows, limit) + if err != nil { + return nil, 0, err + } + return entries, total, nil } diff --git a/internal/store/instance_stats.go b/internal/store/instance_stats.go new file mode 100644 index 0000000..0df9b13 --- /dev/null +++ b/internal/store/instance_stats.go @@ -0,0 +1,288 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Статистика инстанса и карточка пользователя для расширенной админ-панели +// (AGENT.md 7.18, 3.2). Все запросы — только чтение и только для +// администратора инстанса: проверка прав живёт в серверном слое. + +// DailyCount — точка роста: день (UTC) и количество событий за него. +type DailyCount struct { + Date string + Count int +} + +// InstanceUserStats — пользователи инстанса: всего, администраторы, баны, +// новые за неделю и месяц, активные за сутки и рост по дням. +type InstanceUserStats struct { + Total int + Admins int + Banned int + New7Days int + New30Days int + Active24h int + Daily []DailyCount +} + +// InstanceMessageStats — сообщения инстанса: всего, за сутки, неделю, месяц и +// рост по дням за месяц. +type InstanceMessageStats struct { + Total int + Today int + Week int + Month int + Daily []DailyCount +} + +// InstanceFileStats — хранилище файлов: сколько файлов и сколько байт. +type InstanceFileStats struct { + Count int + Bytes int64 +} + +// InstanceGuildStat — сервер в топе: участники и живые сообщения. +type InstanceGuildStat struct { + ID uint64 + Name string + Members int + Messages int +} + +// InstanceStats — сводка по инстансу для вкладки «Статистика». +type InstanceStats struct { + Users InstanceUserStats + Messages InstanceMessageStats + Files InstanceFileStats + Guilds int + Invites int + Bans int + TopGuilds []InstanceGuildStat + BusiestGuilds []InstanceGuildStat +} + +// statsGrowthDays — окно роста по дням: месяц показывает и всплески, и +// общую динамику, не раздувая ответ. +const statsGrowthDays = 30 + +// topGuildsLimit — сколько серверов показывать в каждом топе. +const topGuildsLimit = 5 + +// instanceStatsSince возвращает границу окна в формате хранения времени. +func (s *Store) instanceStatsSince(days int) string { + return s.Timestamp(time.Now().UTC().AddDate(0, 0, -days)) +} + +// InstanceStats собирает сводку по инстансу (AGENT.md 7.18). Запросы идут по +// существующим индексам; подсчёт сообщений — по живых записям (удалённые не +// считаем: «рост сообщений» должен отражать переписку, а не историю правок). +func (s *Store) InstanceStats(ctx context.Context) (*InstanceStats, error) { + stats := &InstanceStats{} + since30 := s.instanceStatsSince(30) + + // Пользователи: один запрос с условными суммами дешевле пяти. + if err := s.reader.QueryRowContext(ctx, ` + SELECT COUNT(*), + COALESCE(SUM(is_instance_admin = 1), 0), + COALESCE(SUM(created_at >= ?), 0), + COALESCE(SUM(created_at >= ?), 0), + COALESCE(SUM(last_seen_at >= ?), 0) + FROM users WHERE deleted_at IS NULL`, + s.instanceStatsSince(7), since30, s.instanceStatsSince(1), + ).Scan(&stats.Users.Total, &stats.Users.Admins, &stats.Users.New7Days, + &stats.Users.New30Days, &stats.Users.Active24h); err != nil { + return nil, err + } + if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM instance_bans`).Scan(&stats.Bans); err != nil { + return nil, err + } + stats.Users.Banned = stats.Bans + dailyUsers, err := s.dailyCounts(ctx, ` + SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM users + WHERE deleted_at IS NULL AND created_at >= ? GROUP BY day ORDER BY day`, since30) + if err != nil { + return nil, err + } + stats.Users.Daily = dailyUsers + + // Сообщения: всего, за сутки, неделю и месяц. Удалённые сообщения в базе не + // хранятся (удаление физическое), поэтому отдельного условия нет. + if err := s.reader.QueryRowContext(ctx, ` + SELECT COUNT(*), + COALESCE(SUM(created_at >= ?), 0), + COALESCE(SUM(created_at >= ?), 0), + COALESCE(SUM(created_at >= ?), 0) + FROM messages`, + s.instanceStatsSince(1), s.instanceStatsSince(7), since30, + ).Scan(&stats.Messages.Total, &stats.Messages.Today, &stats.Messages.Week, + &stats.Messages.Month); err != nil { + return nil, err + } + dailyMessages, err := s.dailyCounts(ctx, ` + SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM messages + WHERE created_at >= ? GROUP BY day ORDER BY day`, since30) + if err != nil { + return nil, err + } + stats.Messages.Daily = dailyMessages + + if err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM files`). + Scan(&stats.Files.Count, &stats.Files.Bytes); err != nil { + return nil, err + } + if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guilds`).Scan(&stats.Guilds); err != nil { + return nil, err + } + // Действующие инвайты: отозванные удаляются из таблицы, просроченные + // отсекаем по времени. + if err := s.reader.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM invites + WHERE expires_at IS NULL OR expires_at > ?`, + s.Now()).Scan(&stats.Invites); err != nil { + return nil, err + } + + if stats.TopGuilds, err = s.topGuilds(ctx, "members"); err != nil { + return nil, err + } + if stats.BusiestGuilds, err = s.topGuilds(ctx, "messages"); err != nil { + return nil, err + } + return stats, nil +} + +// dailyCounts выполняет запрос «день → количество» и складывает результат. +func (s *Store) dailyCounts(ctx context.Context, query string, args ...any) ([]DailyCount, error) { + rows, err := s.reader.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make([]DailyCount, 0, statsGrowthDays) + for rows.Next() { + var item DailyCount + if err := rows.Scan(&item.Date, &item.Count); err != nil { + return nil, err + } + counts = append(counts, item) + } + return counts, rows.Err() +} + +// topGuilds отдаёт верхушку серверов по участникам или по живым сообщениям. +// Подзапросы вместо JOIN GROUP BY: у `messages` индекс по (channel_id, id), и +// коррелированный подзапрос читает только нужные строки. +func (s *Store) topGuilds(ctx context.Context, order string) ([]InstanceGuildStat, error) { + orderBy := "members DESC, g.id" + if order == "messages" { + orderBy = "messages DESC, g.id" + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT g.id, g.name, + (SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS members, + (SELECT COUNT(*) FROM messages msg + JOIN channels c ON c.id = msg.channel_id + WHERE c.guild_id = g.id) AS messages + FROM guilds g ORDER BY `+orderBy+` LIMIT ?`, topGuildsLimit) + if err != nil { + return nil, err + } + defer rows.Close() + + guilds := make([]InstanceGuildStat, 0, topGuildsLimit) + for rows.Next() { + var item InstanceGuildStat + if err := rows.Scan(&item.ID, &item.Name, &item.Members, &item.Messages); err != nil { + return nil, err + } + guilds = append(guilds, item) + } + return guilds, rows.Err() +} + +// MembershipRole — роль участника сервера для карточки пользователя. +type MembershipRole struct { + ID uint64 + Name string + Color int64 +} + +// UserGuildMembership — участие пользователя в сервере (AGENT.md 7.18): +// ник, дата вступления и роли — администратору инстанса видно везде. +type UserGuildMembership struct { + GuildID uint64 + GuildName string + Nickname string + JoinedAt time.Time + Roles []MembershipRole +} + +// ListUserGuildMemberships отдаёт серверы пользователя с ролями: два запроса +// вместо N+1 по серверам. +func (s *Store) ListUserGuildMemberships(ctx context.Context, userID uint64) ([]UserGuildMembership, error) { + memberships, err := func() ([]UserGuildMembership, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT m.guild_id, g.name, m.nickname, m.joined_at + FROM guild_members m JOIN guilds g ON g.id = m.guild_id + WHERE m.user_id = ? ORDER BY m.joined_at, g.id`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + collected := make([]UserGuildMembership, 0, 8) + for rows.Next() { + var ( + membership UserGuildMembership + nickname sql.NullString + joinedAt string + ) + if err := rows.Scan(&membership.GuildID, &membership.GuildName, &nickname, &joinedAt); err != nil { + return nil, err + } + if nickname.Valid { + membership.Nickname = nickname.String + } + membership.JoinedAt = parseTimestamp(joinedAt) + membership.Roles = []MembershipRole{} + collected = append(collected, membership) + } + return collected, rows.Err() + }() + if err != nil { + return nil, err + } + // Индекс «сервер → место в списке»: роли приходят вторым запросом. + index := make(map[uint64]int, len(memberships)) + for position, membership := range memberships { + index[membership.GuildID] = position + } + + roleRows, err := s.reader.QueryContext(ctx, ` + SELECT mr.guild_id, r.id, r.name, r.color + FROM member_roles mr JOIN roles r ON r.id = mr.role_id + WHERE mr.user_id = ? ORDER BY r.position DESC, r.id`, int64(userID)) + if err != nil { + return nil, err + } + defer roleRows.Close() + + for roleRows.Next() { + var ( + role MembershipRole + guildID uint64 + ) + if err := roleRows.Scan(&guildID, &role.ID, &role.Name, &role.Color); err != nil { + return nil, err + } + if position, ok := index[guildID]; ok { + memberships[position].Roles = append(memberships[position].Roles, role) + } + } + return memberships, roleRows.Err() +} diff --git a/internal/store/instance_stats_test.go b/internal/store/instance_stats_test.go new file mode 100644 index 0000000..6a7b19f --- /dev/null +++ b/internal/store/instance_stats_test.go @@ -0,0 +1,364 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "glchat/internal/database" +) + +// newInstanceStore открывает временную БД с миграциями: расширенная +// админ-панель (AGENT.md 7.18) читает пользователей, серверы, сообщения и +// аудит, поэтому нужен настоящий набор таблиц. +func newInstanceStore(t *testing.T) *Store { + t.Helper() + db, err := database.Open(context.Background(), database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("открыть тестовую БД: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("закрыть тестовую БД: %v", err) + } + }) + return New(db) +} + +func createInstanceUser(t *testing.T, st *Store, username string) *User { + t.Helper() + user, err := st.CreateUser(context.Background(), CreateUserParams{ + Username: username, + EmailEnc: "enc-" + username, + EmailIndex: "idx-" + username, + PasswordHash: "hash", + }) + if err != nil { + t.Fatalf("создать пользователя %s: %v", username, err) + } + return user +} + +// TestInstanceStatsAggregates проверяет сводку панели: пользователи, сообщения, +// файлы, серверы и топы (AGENT.md 7.18). +func TestInstanceStatsAggregates(t *testing.T) { + ctx := context.Background() + st := newInstanceStore(t) + owner := createInstanceUser(t, st, "stats_owner") + member := createInstanceUser(t, st, "stats_member") + if err := st.BanInstanceUser(ctx, member.ID, owner.ID, "спам"); err != nil { + t.Fatalf("забанить пользователя: %v", err) + } + if err := st.SetInstanceAdmin(ctx, member.ID, true); err != nil { + t.Fatalf("выдать админа: %v", err) + } + + guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Статистика", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("создать сервер: %v", err) + } + for _, userID := range []uint64{owner.ID, member.ID} { + if _, err := st.AddGuildMember(ctx, guild.ID, userID, ""); err != nil { + t.Fatalf("добавить участника: %v", err) + } + } + channel, err := st.CreateChannel(ctx, CreateChannelParams{ + GuildID: &guild.ID, Type: ChannelText, Name: "общий", + }) + if err != nil { + t.Fatalf("создать комнату: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := st.CreateMessage(ctx, CreateMessageParams{ + ChannelID: channel.ID, AuthorID: owner.ID, Content: "привет", + }); err != nil { + t.Fatalf("создать сообщение: %v", err) + } + } + // Вложение лежит в files и считается размером хранилища. + if _, err := st.CreateFile(ctx, CreateFileParams{ + UploaderID: owner.ID, Filename: "a.png", ContentType: "image/png", + SizeBytes: 2048, StoragePath: "files/a", SHA256: "sha-stats", Purpose: "attachment", + }); err != nil { + t.Fatalf("создать файл: %v", err) + } + if _, err := st.CreateInvite(ctx, CreateInviteParams{ + Code: "stats-invite", GuildID: guild.ID, CreatorID: owner.ID, MaxAgeSec: 3600, + }); err != nil { + t.Fatalf("создать инвайт: %v", err) + } + // Отозванный инвайт действующим не считается. + if _, err := st.CreateInvite(ctx, CreateInviteParams{ + Code: "stats-revoked", GuildID: guild.ID, CreatorID: owner.ID, + }); err != nil { + t.Fatalf("создать инвайт: %v", err) + } + if err := st.DeleteInvite(ctx, "stats-revoked"); err != nil { + t.Fatalf("отозвать инвайт: %v", err) + } + + stats, err := st.InstanceStats(ctx) + if err != nil { + t.Fatalf("InstanceStats: %v", err) + } + if stats.Users.Total < 2 || stats.Users.New7Days < 2 || stats.Users.New30Days < 2 { + t.Fatalf("пользователи в сводке: %+v", stats.Users) + } + if stats.Users.Admins != 1 { + t.Fatalf("администраторов = %d, want 1", stats.Users.Admins) + } + if stats.Bans != 1 || stats.Users.Banned != 1 { + t.Fatalf("банов = %d/%d, want 1", stats.Bans, stats.Users.Banned) + } + if len(stats.Users.Daily) == 0 { + t.Fatal("рост пользователей по дням пуст") + } + if stats.Messages.Total != 3 || stats.Messages.Today != 3 || stats.Messages.Month != 3 { + t.Fatalf("сообщения в сводке: %+v", stats.Messages) + } + if len(stats.Messages.Daily) == 0 { + t.Fatal("рост сообщений по дням пуст") + } + if stats.Files.Count != 1 || stats.Files.Bytes != 2048 { + t.Fatalf("файлы в сводке: %+v", stats.Files) + } + if stats.Guilds != 1 { + t.Fatalf("серверов = %d, want 1", stats.Guilds) + } + if stats.Invites != 1 { + t.Fatalf("действующих инвайтов = %d, want 1", stats.Invites) + } + if len(stats.TopGuilds) != 1 || stats.TopGuilds[0].Members != 2 { + t.Fatalf("топ по участникам: %+v", stats.TopGuilds) + } + if len(stats.BusiestGuilds) != 1 || stats.BusiestGuilds[0].Messages != 3 { + t.Fatalf("топ по сообщениям: %+v", stats.BusiestGuilds) + } +} + +// TestInstanceAuditFilters: журнал инстанса фильтруется по действию, актору, +// цели, серверу и периоду, а выдача листается (AGENT.md 7.10, 7.18). +func TestInstanceAuditFilters(t *testing.T) { + ctx := context.Background() + st := newInstanceStore(t) + admin := createInstanceUser(t, st, "audit_admin") + target := createInstanceUser(t, st, "audit_target") + if err := st.SetInstanceAdmin(ctx, admin.ID, true); err != nil { + t.Fatalf("выдать админа: %v", err) + } + + record := func(action, targetType string, targetID *uint64, reason string) { + t.Helper() + if err := st.RecordAudit(ctx, AuditParams{ + ActorID: &admin.ID, ActorInstanceAdmin: true, Action: action, + TargetType: targetType, TargetID: targetID, Reason: reason, + }); err != nil { + t.Fatalf("записать аудит %s: %v", action, err) + } + } + record("instance.user_ban", "user", &target.ID, "спам") + record("instance.user_unban", "user", &target.ID, "") + record("instance.settings_update", "instance", nil, "") + // Запись обычного пользователя внутри сервера в журнал инстанса не + // попадает, а запись без сервера (например, ключ доступа) — попадает: + // так устроена область журнала (AGENT.md 7.18). + someone := createInstanceUser(t, st, "audit_someone") + guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Аудит", OwnerID: someone.ID}) + if err != nil { + t.Fatalf("создать сервер: %v", err) + } + if err := st.RecordAudit(ctx, AuditParams{ + ActorID: &someone.ID, GuildID: &guild.ID, Action: "channel.create", TargetType: "channel", + }); err != nil { + t.Fatalf("записать аудит сервера: %v", err) + } + if err := st.RecordAudit(ctx, AuditParams{ + ActorID: &someone.ID, Action: "user.passkey_add", TargetType: "user", TargetID: &someone.ID, + }); err != nil { + t.Fatalf("записать аудит без сервера: %v", err) + } + + all, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{}) + if err != nil { + t.Fatalf("журнал целиком: %v", err) + } + if total != 4 || len(all) != 4 { + t.Fatalf("журнал = %d строк (total %d), want 4", len(all), total) + } + + byAction, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Action: "instance.user_ban"}) + if err != nil || total != 1 || len(byAction) != 1 { + t.Fatalf("фильтр по действию: %d строк (total %d), err = %v", len(byAction), total, err) + } + byTarget, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{TargetID: target.ID}) + if err != nil || total != 2 { + t.Fatalf("фильтр по цели: %d (total %d), err = %v", len(byTarget), total, err) + } + byActor, actorTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{ActorID: someone.ID}) + if err != nil || actorTotal != 1 || len(byActor) != 1 { + t.Fatalf("записи без сервера по актору: %d (total %d), err = %v", len(byActor), actorTotal, err) + } + _, guildTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{GuildID: guild.ID}) + if err != nil || guildTotal != 0 { + t.Fatalf("аудит сервера не должен попадать в журнал инстанса: total %d, err = %v", guildTotal, err) + } + + // Период: сегодняшние записи видны, «завтра» — нет. + tomorrow := time.Now().UTC().AddDate(0, 0, 1) + future, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &tomorrow}) + if err != nil || total != 0 || len(future) != 0 { + t.Fatalf("фильтр «с завтра»: %d (total %d), err = %v", len(future), total, err) + } + yesterday := time.Now().UTC().AddDate(0, 0, -1) + recent, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &yesterday}) + if err != nil || total != 4 { + t.Fatalf("фильтр «с вчера»: %d (total %d), err = %v", len(recent), total, err) + } + + // Пагинация: страницы не пересекаются, total не зависит от страницы. + first, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2}) + if err != nil || len(first) != 2 || total != 4 { + t.Fatalf("первая страница: %d (total %d), err = %v", len(first), total, err) + } + second, _, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2, Offset: 3}) + if err != nil || len(second) != 1 { + t.Fatalf("вторая страница: %d, err = %v", len(second), err) + } + if first[0].ID == second[0].ID { + t.Fatal("страницы журнала пересекаются") + } + + actions, err := st.ListInstanceAuditActions(ctx) + if err != nil { + t.Fatalf("список действий: %v", err) + } + if len(actions) != 4 || actions[0] != "instance.settings_update" || actions[3] != "user.passkey_add" { + t.Fatalf("действия журнала = %v", actions) + } + + // История по пользователю: он цель в двух записях. + userEntries, userTotal, err := st.ListUserAudit(ctx, target.ID, 10, 0) + if err != nil || userTotal != 2 || len(userEntries) != 2 { + t.Fatalf("аудит пользователя: %d (total %d), err = %v", len(userEntries), userTotal, err) + } +} + +// TestInstanceGuildListFiltersAndMemberships: список серверов панели +// фильтруется и листается, а карточка участника показывает роли (AGENT.md 7.18). +func TestInstanceGuildListFiltersAndMemberships(t *testing.T) { + ctx := context.Background() + st := newInstanceStore(t) + owner := createInstanceUser(t, st, "list_owner") + other := createInstanceUser(t, st, "list_other") + + first, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Альфа", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("создать сервер: %v", err) + } + second, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Бета", OwnerID: other.ID}) + if err != nil { + t.Fatalf("создать сервер: %v", err) + } + if _, err := st.AddGuildMember(ctx, second.ID, other.ID, ""); err != nil { + t.Fatalf("добавить владельца: %v", err) + } + main, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Главный", OwnerID: owner.ID, IsMain: true}) + if err != nil { + t.Fatalf("создать главный сервер: %v", err) + } + if _, err := st.AddGuildMember(ctx, first.ID, owner.ID, ""); err != nil { + t.Fatalf("добавить владельца: %v", err) + } + if _, err := st.AddGuildMember(ctx, first.ID, other.ID, "гость"); err != nil { + t.Fatalf("добавить участника: %v", err) + } + role, err := st.CreateRole(ctx, CreateRoleParams{ + GuildID: first.ID, Name: "Модератор", Color: 0xFF0000, Position: 5, Permissions: 8, + }) + if err != nil { + t.Fatalf("создать роль: %v", err) + } + if err := st.AssignRole(ctx, first.ID, other.ID, role.ID); err != nil { + t.Fatalf("выдать роль: %v", err) + } + + byName, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Query: "альф"}) + if err != nil || total != 1 || len(byName) != 1 { + t.Fatalf("поиск по названию: %d (total %d), err = %v", len(byName), total, err) + } + if byName[0].MemberCount != 2 { + t.Fatalf("участников в списке = %d, want 2", byName[0].MemberCount) + } + byOwner, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{OwnerID: owner.ID}) + if err != nil || total != 2 { + t.Fatalf("фильтр по владельцу: %d (total %d), err = %v", len(byOwner), total, err) + } + mainOnly, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{MainOnly: true}) + if err != nil || total != 1 || mainOnly[0].ID != main.ID { + t.Fatalf("главный сервер: %+v (total %d), err = %v", mainOnly, total, err) + } + page, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Limit: 2, Offset: 2}) + if err != nil || len(page) != 1 || total != 3 { + t.Fatalf("страница серверов: %d (total %d), err = %v", len(page), total, err) + } + + memberships, err := st.ListUserGuildMemberships(ctx, other.ID) + if err != nil { + t.Fatalf("серверы пользователя: %v", err) + } + if len(memberships) != 2 { + t.Fatalf("серверов у пользователя = %d, want 2", len(memberships)) + } + found := false + for _, membership := range memberships { + if membership.GuildID != first.ID { + continue + } + found = true + if membership.Nickname != "гость" || len(membership.Roles) != 1 || membership.Roles[0].Name != "Модератор" { + t.Fatalf("участие в сервере = %+v", membership) + } + } + if !found { + t.Fatal("сервер с ролью не найден в карточке пользователя") + } +} + +// TestUserSessionRevokeChecksOwner: отозвать можно только сессию самого +// пользователя, чужая не удаляется (AGENT.md 7.18). +func TestUserSessionRevokeChecksOwner(t *testing.T) { + ctx := context.Background() + st := newInstanceStore(t) + owner := createInstanceUser(t, st, "session_owner") + other := createInstanceUser(t, st, "session_other") + + session, err := st.CreateSession(ctx, owner.ID, CreateSessionParams{ + TokenHash: "hash-session", UserAgent: "тест", IP: "127.0.0.1", + }) + if err != nil { + t.Fatalf("создать сессию: %v", err) + } + if _, err := st.UserSession(ctx, other.ID, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("чужая сессия = %v, want ErrNotFound", err) + } + deleted, err := st.DeleteUserSession(ctx, other.ID, session.ID) + if err != nil || deleted { + t.Fatalf("чужое удаление: deleted = %v, err = %v", deleted, err) + } + if _, err := st.UserSession(ctx, owner.ID, session.ID); err != nil { + t.Fatalf("своя сессия недоступна: %v", err) + } + deleted, err = st.DeleteUserSession(ctx, owner.ID, session.ID) + if err != nil || !deleted { + t.Fatalf("своё удаление: deleted = %v, err = %v", deleted, err) + } + if _, err := st.UserSession(ctx, owner.ID, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("сессия после отзыва = %v, want ErrNotFound", err) + } +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go index c493b75..837a41d 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -142,6 +142,31 @@ func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID u return err } +// UserSession отдаёт сессию, только если она принадлежит пользователю: админ +// панели отзывает конкретное устройство (AGENT.md 7.18), и чужой +// идентификатор сессии не должен ничего отзывать. +func (s *Store) UserSession(ctx context.Context, userID, sessionID uint64) (*Session, error) { + row := s.reader.QueryRowContext(ctx, ` + SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at + FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID)) + return scanSession(row) +} + +// DeleteUserSession отзывает одно устройство пользователя. Возвращает false, +// если сессии с таким идентификатором у него нет. +func (s *Store) DeleteUserSession(ctx context.Context, userID, sessionID uint64) (bool, error) { + result, err := s.writer.ExecContext(ctx, + `DELETE FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID)) + if err != nil { + return false, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} + // DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4). func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) { result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())