Files
glchat/internal/store/instance.go
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

214 lines
6.5 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"math"
"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
raw map[string]string
}
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),
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 (s *Store) ListAuditLog(ctx context.Context, guildID uint64, 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 guild_id = ? ORDER BY id DESC LIMIT ?`, int64(guildID), limit)
if err != nil {
return nil, err
}
return scanAuditEntries(rows, limit)
}
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
}
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)
}