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:
+140
-8
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -216,18 +217,149 @@ func parseUint(value string) (uint64, bool) {
|
||||
return result, true
|
||||
}
|
||||
|
||||
// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов
|
||||
// инстанса (в том числе внутри серверов) и записи без привязки к серверу,
|
||||
// которые переживают удаление сервера (AGENT.md 7.18).
|
||||
func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
// MaxInstanceAuditLimit — предел выдачи журнала инстанса за один запрос:
|
||||
// панель листает страницы по 50–200 строк, экспорт CSV берёт до 5000
|
||||
// (AGENT.md 7.10, 7.18). Больше не отдаём: выгрузка всего журнала одним
|
||||
// ответом съедала бы память на 1 vCPU / 2 GB.
|
||||
const MaxInstanceAuditLimit = 5000
|
||||
|
||||
// InstanceAuditFilter — условия выборки журнала инстанса (AGENT.md 7.10):
|
||||
// действие, актор, цель, сервер и период. Незаполненные поля не ограничивают
|
||||
// выборку, поэтому один тип используется и панелью, и экспортом.
|
||||
type InstanceAuditFilter struct {
|
||||
Action string
|
||||
ActorID uint64
|
||||
TargetID uint64
|
||||
GuildID uint64
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// instanceAuditConditions собирает WHERE для журнала инстанса: базовая область
|
||||
// — действия администраторов инстанса (в том числе внутри серверов) и записи
|
||||
// без привязки к серверу, которые переживают удаление сервера (AGENT.md 7.18).
|
||||
// Время сравнивается строками: в базе лежит ISO 8601 UTC с миллисекундами,
|
||||
// поэтому лексикографический порядок совпадает с хронологическим.
|
||||
func (s *Store) instanceAuditConditions(filter InstanceAuditFilter) (string, []any) {
|
||||
clauses := []string{"(actor_instance_admin = 1 OR guild_id IS NULL)"}
|
||||
args := make([]any, 0, 6)
|
||||
if filter.Action != "" {
|
||||
clauses = append(clauses, "action = ?")
|
||||
args = append(args, filter.Action)
|
||||
}
|
||||
if filter.ActorID != 0 {
|
||||
clauses = append(clauses, "actor_id = ?")
|
||||
args = append(args, mustID(filter.ActorID))
|
||||
}
|
||||
if filter.TargetID != 0 {
|
||||
clauses = append(clauses, "target_id = ?")
|
||||
args = append(args, mustID(filter.TargetID))
|
||||
}
|
||||
if filter.GuildID != 0 {
|
||||
clauses = append(clauses, "guild_id = ?")
|
||||
args = append(args, mustID(filter.GuildID))
|
||||
}
|
||||
if filter.Since != nil {
|
||||
clauses = append(clauses, "created_at >= ?")
|
||||
args = append(args, s.Timestamp(*filter.Since))
|
||||
}
|
||||
if filter.Until != nil {
|
||||
clauses = append(clauses, "created_at <= ?")
|
||||
args = append(args, s.Timestamp(*filter.Until))
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// ListInstanceAuditFiltered отдаёт страницу журнала инстанса вместе с общим
|
||||
// числом записей под тот же фильтр: панели нужна пагинация, а не только
|
||||
// первые N строк (AGENT.md 7.18).
|
||||
func (s *Store) ListInstanceAuditFiltered(ctx context.Context, filter InstanceAuditFilter) ([]AuditEntry, int, error) {
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 50
|
||||
}
|
||||
if filter.Limit > MaxInstanceAuditLimit {
|
||||
filter.Limit = MaxInstanceAuditLimit
|
||||
}
|
||||
if filter.Offset < 0 {
|
||||
filter.Offset = 0
|
||||
}
|
||||
where, args := s.instanceAuditConditions(filter)
|
||||
var total int
|
||||
if err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM audit_log WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
queryArgs := make([]any, 0, len(args)+2)
|
||||
queryArgs = append(queryArgs, args...)
|
||||
queryArgs = append(queryArgs, filter.Limit, filter.Offset)
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE actor_instance_admin = 1 OR guild_id IS NULL ORDER BY id DESC LIMIT ?`, limit)
|
||||
FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries, err := scanAuditEntries(rows, filter.Limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// ListInstanceAuditActions перечисляет действия, которые встречаются в журнале
|
||||
// инстанса: панель предлагает их списком, чтобы фильтр не набирали руками.
|
||||
func (s *Store) ListInstanceAuditActions(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT DISTINCT action FROM audit_log
|
||||
WHERE actor_instance_admin = 1 OR guild_id IS NULL
|
||||
ORDER BY action`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAuditEntries(rows, limit)
|
||||
defer rows.Close()
|
||||
|
||||
actions := make([]string, 0, 16)
|
||||
for rows.Next() {
|
||||
var action string
|
||||
if err := rows.Scan(&action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return actions, rows.Err()
|
||||
}
|
||||
|
||||
// ListUserAudit — история аудита по конкретному пользователю для его карточки:
|
||||
// записи, где он цель (target_type = user) или актор (AGENT.md 7.18). Личные
|
||||
// беседы в аудит не попадают вовсе, поэтому приватность переписки не
|
||||
// нарушается даже полной выборкой по актору.
|
||||
func (s *Store) ListUserAudit(ctx context.Context, userID uint64, limit, offset int) ([]AuditEntry, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
const where = "(target_type = 'user' AND target_id = ?) OR actor_id = ?"
|
||||
var total int
|
||||
if err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM audit_log WHERE `+where, mustID(userID), mustID(userID)).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
mustID(userID), mustID(userID), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries, err := scanAuditEntries(rows, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user