package store import ( "context" "database/sql" "encoding/json" "math" "strconv" "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 } // ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов // инстанса (в том числе внутри серверов) и записи без привязки к серверу, // которые переживают удаление сервера (AGENT.md 7.18). func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) { if limit <= 0 || limit > 200 { limit = 50 } 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) if err != nil { return nil, err } return scanAuditEntries(rows, limit) }