2026-09-19 21:24:17 +03:00
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"math"
|
2026-09-21 22:37:47 +03:00
|
|
|
"strconv"
|
2026-09-26 16:44:49 +03:00
|
|
|
"strings"
|
2026-09-19 21:24:17 +03:00
|
|
|
"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
|
2026-09-21 22:37:47 +03:00
|
|
|
// Лимиты медиа в байтах (AGENT.md 7.7): 0 — значение по умолчанию.
|
|
|
|
|
MaxAvatarSize int64
|
|
|
|
|
MaxGuildImageSize int64
|
|
|
|
|
MaxEmojiSize int64
|
|
|
|
|
MaxSoundSize int64
|
|
|
|
|
MaxCosmeticSize int64
|
|
|
|
|
raw map[string]string
|
2026-09-19 21:24:17 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 22:37:47 +03:00
|
|
|
// MediaLimits по умолчанию: совпадают со спецификацией 7.7.
|
|
|
|
|
const (
|
|
|
|
|
DefaultAvatarSize int64 = 5 << 20
|
|
|
|
|
DefaultGuildImageSize int64 = 5 << 20
|
|
|
|
|
DefaultEmojiSize int64 = 512 << 10
|
|
|
|
|
DefaultSoundSize int64 = 512 << 10
|
|
|
|
|
DefaultCosmeticSize int64 = 5 << 20
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-19 21:24:17 +03:00
|
|
|
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),
|
2026-09-21 22:37:47 +03:00
|
|
|
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),
|
2026-09-19 21:24:17 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) {
|
2026-09-19 21:24:17 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 22:37:47 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:24:17 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
|
2026-09-26 16:44:49 +03:00
|
|
|
// 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
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
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)
|
2026-09-19 21:50:06 +03:00
|
|
|
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
|
2026-09-26 16:44:49 +03:00
|
|
|
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`)
|
2026-09-19 21:50:06 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
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
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|