3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
366 lines
12 KiB
Go
366 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// InstanceSettings — настраиваемые флаги и лимиты инстанса (AGENT.md 6.5).
|
|
type InstanceSettings struct {
|
|
RegistrationEnabled bool
|
|
AntiBotEnabled bool
|
|
AllowGuildCreation bool
|
|
MainGuildID uint64
|
|
MaxGuildsPerUser int
|
|
MaxMembersPerGuild int
|
|
MaxMessageLength int
|
|
AuditRetentionDays int
|
|
// Лимиты медиа в байтах (AGENT.md 7.7): 0 — значение по умолчанию.
|
|
MaxAvatarSize int64
|
|
MaxGuildImageSize int64
|
|
MaxEmojiSize int64
|
|
MaxSoundSize int64
|
|
MaxCosmeticSize int64
|
|
raw map[string]string
|
|
}
|
|
|
|
// MediaLimits по умолчанию: совпадают со спецификацией 7.7.
|
|
const (
|
|
DefaultAvatarSize int64 = 5 << 20
|
|
DefaultGuildImageSize int64 = 5 << 20
|
|
DefaultEmojiSize int64 = 512 << 10
|
|
DefaultSoundSize int64 = 512 << 10
|
|
DefaultCosmeticSize int64 = 5 << 20
|
|
)
|
|
|
|
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
|
|
|
|
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
|
|
rows, err := s.reader.QueryContext(ctx, instanceSettingsQuery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
values := map[string]string{}
|
|
for rows.Next() {
|
|
var key, value string
|
|
if err := rows.Scan(&key, &value); err != nil {
|
|
return nil, err
|
|
}
|
|
values[key] = value
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return settingsFromMap(values), nil
|
|
}
|
|
|
|
func settingsFromMap(values map[string]string) *InstanceSettings {
|
|
settings := &InstanceSettings{
|
|
RegistrationEnabled: values["registration_enabled"] == "true",
|
|
AntiBotEnabled: values["anti_bot_enabled"] == "true",
|
|
AllowGuildCreation: values["allow_guild_creation"] != "false",
|
|
MaxGuildsPerUser: parseSettingInt(values["max_guilds_per_user"], 10),
|
|
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
|
|
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
|
|
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
|
|
MaxAvatarSize: parseSettingInt64(values["max_avatar_size"], DefaultAvatarSize),
|
|
MaxGuildImageSize: parseSettingInt64(values["max_guild_image_size"], DefaultGuildImageSize),
|
|
MaxEmojiSize: parseSettingInt64(values["max_emoji_size"], DefaultEmojiSize),
|
|
MaxSoundSize: parseSettingInt64(values["max_sound_size"], DefaultSoundSize),
|
|
MaxCosmeticSize: parseSettingInt64(values["max_cosmetic_size"], DefaultCosmeticSize),
|
|
raw: values,
|
|
}
|
|
if mainGuild := values["main_guild_id"]; mainGuild != "" {
|
|
if parsed, ok := parseUint(mainGuild); ok {
|
|
settings.MainGuildID = parsed
|
|
}
|
|
}
|
|
return settings
|
|
}
|
|
|
|
// Value возвращает исходное значение настройки (для API и CLI).
|
|
func (s *InstanceSettings) Value(key string) string { return s.raw[key] }
|
|
|
|
func (s *Store) SetInstanceSetting(ctx context.Context, key, value string) error {
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
key, value, s.Now())
|
|
return err
|
|
}
|
|
|
|
// AuditEntry — запись аудита сервера (AGENT.md 6.1, 7.10).
|
|
type AuditEntry struct {
|
|
ID uint64
|
|
GuildID *uint64
|
|
ActorID *uint64
|
|
ActorInstanceAdmin bool
|
|
Action string
|
|
TargetType string
|
|
TargetID *uint64
|
|
Reason string
|
|
Changes json.RawMessage
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type AuditParams struct {
|
|
GuildID *uint64
|
|
ActorID *uint64
|
|
ActorInstanceAdmin bool
|
|
Action string
|
|
TargetType string
|
|
TargetID *uint64
|
|
Reason string
|
|
Changes any
|
|
}
|
|
|
|
// RecordAudit пишет действие в аудит; ошибка записи не должна ломать основную
|
|
// операцию, поэтому вызывающий код логирует её отдельно (AGENT.md 7.18).
|
|
func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error {
|
|
changes := "{}"
|
|
if params.Changes != nil {
|
|
if encoded, err := json.Marshal(params.Changes); err == nil {
|
|
changes = string(encoded)
|
|
}
|
|
}
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO audit_log (id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
int64(s.NextID()), nullableID(params.GuildID), nullableID(params.ActorID),
|
|
boolToInt(params.ActorInstanceAdmin), params.Action, params.TargetType,
|
|
nullableID(params.TargetID), params.Reason, changes, s.Now())
|
|
return err
|
|
}
|
|
|
|
func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) {
|
|
defer rows.Close()
|
|
|
|
entries := make([]AuditEntry, 0, limit)
|
|
for rows.Next() {
|
|
var (
|
|
entry AuditEntry
|
|
guildID sql.NullInt64
|
|
actorID sql.NullInt64
|
|
targetID sql.NullInt64
|
|
isAdmin int
|
|
changes string
|
|
createdAt string
|
|
)
|
|
if err := rows.Scan(&entry.ID, &guildID, &actorID, &isAdmin, &entry.Action,
|
|
&entry.TargetType, &targetID, &entry.Reason, &changes, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
entry.GuildID = optionalID(guildID)
|
|
entry.ActorID = optionalID(actorID)
|
|
entry.TargetID = optionalID(targetID)
|
|
entry.ActorInstanceAdmin = isAdmin == 1
|
|
entry.Changes = json.RawMessage(changes)
|
|
entry.CreatedAt = parseTimestamp(createdAt)
|
|
entries = append(entries, entry)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func nullableID(id *uint64) any {
|
|
if id == nil {
|
|
return nil
|
|
}
|
|
return int64(*id)
|
|
}
|
|
|
|
func optionalID(value sql.NullInt64) *uint64 {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
converted := intToID(value.Int64)
|
|
return &converted
|
|
}
|
|
|
|
// parseSettingInt64 читает настройку-размер в байтах.
|
|
func parseSettingInt64(value string, fallback int64) int64 {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseSettingInt(value string, fallback int) int {
|
|
parsed, ok := parseUint(value)
|
|
if !ok || parsed > math.MaxInt32 {
|
|
return fallback
|
|
}
|
|
return int(parsed)
|
|
}
|
|
|
|
func parseUint(value string) (uint64, bool) {
|
|
var result uint64
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return 0, false
|
|
}
|
|
result = result*10 + uint64(r-'0')
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
// 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 `+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
|
|
}
|
|
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
|
|
}
|