feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
This commit is contained in:
@@ -161,6 +161,13 @@ func run() error {
|
|||||||
} else if updated > 0 {
|
} else if updated > 0 {
|
||||||
logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated))
|
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{
|
srv := server.New(cfg, db, logger, server.Deps{
|
||||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -415,6 +415,33 @@ func (s *Service) InvalidateUserExcept(userID uint64, keepTokenHash, reason stri
|
|||||||
s.invalidateUser(userID, keepTokenHash, reason)
|
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) {
|
func (s *Service) invalidateUser(userID uint64, keepTokenHash, reason string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
targets := make([]*clientSession, 0, 2)
|
targets := make([]*clientSession, 0, 2)
|
||||||
|
|||||||
+716
-21
@@ -3,15 +3,21 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"glchat/internal/bootstrap"
|
"glchat/internal/bootstrap"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
|
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
|
||||||
@@ -49,6 +55,9 @@ type instanceGuildPayload struct {
|
|||||||
type instanceGuildListOutput struct {
|
type instanceGuildListOutput struct {
|
||||||
Body struct {
|
Body struct {
|
||||||
Guilds []instanceGuildPayload `json:"guilds"`
|
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: "Все серверы инстанса",
|
Summary: "Все серверы инстанса",
|
||||||
Tags: []string{"Instance"},
|
Tags: []string{"Instance"},
|
||||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
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 {
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
output := &instanceGuildListOutput{}
|
output := &instanceGuildListOutput{}
|
||||||
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
|
output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows))
|
||||||
for _, guild := range guilds {
|
for _, row := range rows {
|
||||||
payload := instanceGuildPayload{
|
output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{
|
||||||
ID: formatSnowflake(guild.ID),
|
ID: formatSnowflake(row.ID),
|
||||||
Name: guild.Name,
|
Name: row.Name,
|
||||||
OwnerID: formatSnowflake(guild.OwnerID),
|
OwnerID: formatSnowflake(row.OwnerID),
|
||||||
IsMain: guild.IsMain,
|
OwnerName: row.OwnerName,
|
||||||
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
|
IsMain: row.IsMain,
|
||||||
}
|
MemberCount: row.MemberCount,
|
||||||
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
|
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
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.Total = total
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -733,18 +761,238 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
Tags: []string{"Instance"},
|
Tags: []string{"Instance"},
|
||||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
|
// Фильтры журнала (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"`
|
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 {
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
output := &auditListOutput{}
|
output := &instanceAuditOutput{}
|
||||||
output.Body.Entries = auditPayloads(entries)
|
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
|
return output, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -829,3 +1077,450 @@ func newTemporaryPassword() (string, error) {
|
|||||||
}
|
}
|
||||||
return builder.String(), nil
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -147,13 +148,15 @@ type onboardingInput struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// securityEventPayload — событие безопасности без служебных метаданных.
|
// securityEventPayload — событие безопасности владельца аккаунта; карточка
|
||||||
|
// пользователя в админ-панели дополняет его метаданными (AGENT.md 7.18).
|
||||||
type securityEventPayload struct {
|
type securityEventPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type securityEventListOutput struct {
|
type securityEventListOutput struct {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,9 @@ type Server struct {
|
|||||||
hostCPUSampler *sysinfo.Sampler
|
hostCPUSampler *sysinfo.Sampler
|
||||||
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
||||||
metricsLimiter *httpx.RateLimiter
|
metricsLimiter *httpx.RateLimiter
|
||||||
|
// auditExportLimiter — выгрузка журнала инстанса: тяжёлый ответ, поэтому
|
||||||
|
// 5 экспортов в минуту на администратора (AGENT.md 7.18, 8.6).
|
||||||
|
auditExportLimiter *httpx.RateLimiter
|
||||||
// health — кэш тяжёлых проверок инстанса.
|
// health — кэш тяжёлых проверок инстанса.
|
||||||
health healthCache
|
health healthCache
|
||||||
// presence — время последнего обновления last_seen по пользователю.
|
// presence — время последнего обновления last_seen по пользователю.
|
||||||
@@ -153,6 +156,8 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
webhookSeen: map[string]time.Time{},
|
webhookSeen: map[string]time.Time{},
|
||||||
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
|
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
|
||||||
metricsLimiter: httpx.NewRateLimiter(120, 30),
|
metricsLimiter: httpx.NewRateLimiter(120, 30),
|
||||||
|
// Экспорт журнала — редкое и тяжёлое действие: 5 раз в минуту.
|
||||||
|
auditExportLimiter: httpx.NewRateLimiterWindow(5, time.Minute, 5),
|
||||||
sysinfoPaths: sysinfo.DefaultPaths(),
|
sysinfoPaths: sysinfo.DefaultPaths(),
|
||||||
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
|
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
|
||||||
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
|
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
|
||||||
@@ -200,6 +205,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerUserRoutes(s.api)
|
s.registerUserRoutes(s.api)
|
||||||
s.registerGuildRoutes(s.api)
|
s.registerGuildRoutes(s.api)
|
||||||
s.registerInstanceRoutes(s.api)
|
s.registerInstanceRoutes(s.api)
|
||||||
|
s.registerInstanceExportRoutes(apiRouter)
|
||||||
s.registerBanRoutes(s.api)
|
s.registerBanRoutes(s.api)
|
||||||
s.registerMetricsRoutes(s.api)
|
s.registerMetricsRoutes(s.api)
|
||||||
s.registerMessageRoutes(s.api)
|
s.registerMessageRoutes(s.api)
|
||||||
|
|||||||
+144
-8
@@ -59,10 +59,11 @@ func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Gui
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err := s.writer.ExecContext(ctx, `
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at)
|
INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, name_lower, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,8 +108,8 @@ func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildPa
|
|||||||
args []any
|
args []any
|
||||||
)
|
)
|
||||||
if params.Name != nil {
|
if params.Name != nil {
|
||||||
sets = append(sets, "name = ?")
|
sets = append(sets, "name = ?", "name_lower = ?")
|
||||||
args = append(args, *params.Name)
|
args = append(args, *params.Name, strings.ToLower(*params.Name))
|
||||||
}
|
}
|
||||||
if params.Description != nil {
|
if params.Description != nil {
|
||||||
sets = append(sets, "description = ?")
|
sets = append(sets, "description = ?")
|
||||||
@@ -179,6 +180,134 @@ func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) {
|
|||||||
return collectGuilds(rows)
|
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) {
|
func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) {
|
func scanGuild(scanner interface{ Scan(...any) error }, extra ...any) (*Guild, error) {
|
||||||
var (
|
var (
|
||||||
guild Guild
|
guild Guild
|
||||||
iconID sql.NullInt64
|
iconID sql.NullInt64
|
||||||
@@ -320,8 +449,15 @@ func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) {
|
|||||||
limitsJSON string
|
limitsJSON string
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID,
|
// extra — дополнительные колонки запроса (например, имя владельца и число
|
||||||
&guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt)
|
// участников в списке инстанса): порядок сканирования остаётся прежним.
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-8
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,18 +217,149 @@ func parseUint(value string) (uint64, bool) {
|
|||||||
return result, true
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов
|
// MaxInstanceAuditLimit — предел выдачи журнала инстанса за один запрос:
|
||||||
// инстанса (в том числе внутри серверов) и записи без привязки к серверу,
|
// панель листает страницы по 50–200 строк, экспорт CSV берёт до 5000
|
||||||
// которые переживают удаление сервера (AGENT.md 7.18).
|
// (AGENT.md 7.10, 7.18). Больше не отдаём: выгрузка всего журнала одним
|
||||||
func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
// ответом съедала бы память на 1 vCPU / 2 GB.
|
||||||
if limit <= 0 || limit > 200 {
|
const MaxInstanceAuditLimit = 5000
|
||||||
limit = 50
|
|
||||||
|
// 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, `
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,31 @@ func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID u
|
|||||||
return err
|
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).
|
// DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4).
|
||||||
func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||||
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())
|
||||||
|
|||||||
Reference in New Issue
Block a user