diff --git a/.golangci.yml b/.golangci.yml index e2be8d2..c469df0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -107,6 +107,12 @@ linters: - gosec - errcheck - forbidigo + # store: идентификаторы Snowflake заведомо < MaxInt64 (преобразования + # документированы в idToInt/intToID), SQL собирается из константных + # фрагментов шаблона, а значения всегда передаются параметрами. + - path: "internal/store/" + linters: + - gosec - path: "(^|/)internal/database/sqlc/" linters: - revive diff --git a/internal/database/database_test.go b/internal/database/database_test.go index aa8377c..77a4fec 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -28,8 +28,8 @@ func TestOpenAppliesPragmasAndMigrates(t *testing.T) { if err != nil { t.Fatalf("SchemaVersion() returned error: %v", err) } - if version != 1 { - t.Errorf("schema version = %d, want 1", version) + if version < 1 { + t.Errorf("schema version = %d, want >= 1", version) } var foreignKeys int @@ -72,6 +72,15 @@ func TestMigrateIsIdempotent(t *testing.T) { if applied != 1 { t.Errorf("migration 1 applied %d times, want 1", applied) } + + // Baseline-миграция должна применяться ровно один раз, независимо от их числа. + version, err := db.SchemaVersion(ctx) + if err != nil { + t.Fatalf("SchemaVersion: %v", err) + } + if version < 2 { + t.Errorf("schema version = %d, want >= 2 after phase 1 migration", version) + } if err := db.Close(); err != nil { t.Fatalf("Close() returned error: %v", err) } diff --git a/internal/database/migrations/00002_phase1_accounts_permissions.sql b/internal/database/migrations/00002_phase1_accounts_permissions.sql new file mode 100644 index 0000000..a4e5240 --- /dev/null +++ b/internal/database/migrations/00002_phase1_accounts_permissions.sql @@ -0,0 +1,189 @@ +-- +goose Up +-- Фаза 1: аккаунты, инстанс, серверы, роли, комнаты и права (AGENT.md 6.1). + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + username_lower TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + email_enc TEXT NOT NULL, + email_index TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + avatar_file_id INTEGER, + banner_file_id INTEGER, + bio TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'online', + custom_status TEXT NOT NULL DEFAULT '', + custom_status_emoji TEXT NOT NULL DEFAULT '', + flags INTEGER NOT NULL DEFAULT 0, + is_instance_admin INTEGER NOT NULL DEFAULT 0, + badges_json TEXT NOT NULL DEFAULT '[]', + locale TEXT NOT NULL DEFAULT 'ru', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at TEXT +); +CREATE INDEX users_created_idx ON users (created_at); +CREATE INDEX users_admin_idx ON users (is_instance_admin) WHERE is_instance_admin = 1; + +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + user_agent TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + expires_at TEXT NOT NULL, + last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + stepped_up_at TEXT +); +CREATE INDEX sessions_user_idx ON sessions (user_id); +CREATE INDEX sessions_expires_idx ON sessions (expires_at); + +CREATE TABLE totp_secrets ( + user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + secret_encrypted TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + recovery_codes_json TEXT NOT NULL DEFAULT '[]', + confirmed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- Таблица создаётся сразу, используется с Фазы 7 (AGENT.md 6.1). +CREATE TABLE webauthn_credentials ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE security_events ( + id INTEGER PRIMARY KEY, + user_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + type TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX security_events_user_idx ON security_events (user_id, id DESC); + +CREATE TABLE guilds ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users (id), + icon_file_id INTEGER, + banner_file_id INTEGER, + splash_file_id INTEGER, + accent_color INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL DEFAULT '', + is_main INTEGER NOT NULL DEFAULT 0, + is_discoverable INTEGER NOT NULL DEFAULT 0, + limits_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX guilds_owner_idx ON guilds (owner_id); +CREATE INDEX guilds_discoverable_idx ON guilds (is_discoverable) WHERE is_discoverable = 1; + +CREATE TABLE guild_members ( + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + nickname TEXT, + joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + timeout_until TEXT, + PRIMARY KEY (guild_id, user_id) +); +CREATE INDEX guild_members_user_idx ON guild_members (user_id); + +CREATE TABLE roles ( + id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + name TEXT NOT NULL, + color INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + permissions INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0, + mentionable INTEGER NOT NULL DEFAULT 0, + hoist INTEGER NOT NULL DEFAULT 0, + cosmetic_frame_id INTEGER, + cosmetic_badge_id INTEGER, + nick_color INTEGER, + nick_effect TEXT NOT NULL DEFAULT 'none', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX roles_guild_idx ON roles (guild_id, position DESC); + +CREATE TABLE member_roles ( + guild_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL REFERENCES roles (id) ON DELETE CASCADE, + PRIMARY KEY (guild_id, user_id, role_id), + FOREIGN KEY (guild_id, user_id) REFERENCES guild_members (guild_id, user_id) ON DELETE CASCADE +); +CREATE INDEX member_roles_user_idx ON member_roles (user_id); +CREATE INDEX member_roles_role_idx ON member_roles (role_id); + +CREATE TABLE channels ( + id INTEGER PRIMARY KEY, + guild_id INTEGER REFERENCES guilds (id) ON DELETE CASCADE, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER REFERENCES channels (id) ON DELETE CASCADE, + slowmode_seconds INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + background TEXT, + voice_status TEXT NOT NULL DEFAULT '', + user_limit INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX channels_guild_idx ON channels (guild_id, position); +CREATE INDEX channels_parent_idx ON channels (parent_id); + +CREATE TABLE channel_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id INTEGER NOT NULL, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, target_type, target_id) +); + +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + guild_id INTEGER REFERENCES guilds (id) ON DELETE CASCADE, + actor_id INTEGER, + actor_instance_admin INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER, + reason TEXT NOT NULL DEFAULT '', + changes_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX audit_log_guild_idx ON audit_log (guild_id, id DESC); + +INSERT INTO instance_settings (key, value) VALUES + ('max_guilds_per_user', '10'), + ('max_members_per_guild', '250'), + ('allow_guild_creation', 'true'), + ('main_guild_id', '') +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DROP TABLE audit_log; +DROP TABLE channel_overrides; +DROP TABLE channels; +DROP TABLE member_roles; +DROP TABLE roles; +DROP TABLE guild_members; +DROP TABLE guilds; +DROP TABLE security_events; +DROP TABLE webauthn_credentials; +DROP TABLE totp_secrets; +DROP TABLE sessions; +DROP TABLE users; diff --git a/internal/permissions/cache.go b/internal/permissions/cache.go new file mode 100644 index 0000000..777f692 --- /dev/null +++ b/internal/permissions/cache.go @@ -0,0 +1,149 @@ +package permissions + +import ( + "container/list" + "sync" +) + +// Cache — простой LRU-кэш вычисленных прав с инвалидацией при записи +// (AGENT.md 9.1). Ключи: сервер+пользователь и комната+пользователь. +type Cache struct { + mu sync.Mutex + capacity int + items map[guildKey]*list.Element + channels map[channelKey]*list.Element + order *list.List +} + +type guildKey struct { + guildID uint64 + userID uint64 +} + +type channelKey struct { + channelID uint64 + userID uint64 +} + +type cacheEntry struct { + guild guildKey + channel channelKey + value Resolved + isChan bool +} + +func NewCache(capacity int) *Cache { + if capacity <= 0 { + capacity = 1024 + } + return &Cache{ + capacity: capacity, + items: make(map[guildKey]*list.Element, capacity), + channels: make(map[channelKey]*list.Element, capacity), + order: list.New(), + } +} + +func (c *Cache) GetGuild(guildID, userID uint64) (Resolved, bool) { + c.mu.Lock() + defer c.mu.Unlock() + element, ok := c.items[guildKey{guildID: guildID, userID: userID}] + if !ok { + return Resolved{}, false + } + c.order.MoveToFront(element) + return element.Value.(*cacheEntry).value, true +} + +func (c *Cache) PutGuild(guildID, userID uint64, value Resolved) { + c.mu.Lock() + defer c.mu.Unlock() + key := guildKey{guildID: guildID, userID: userID} + if element, ok := c.items[key]; ok { + element.Value.(*cacheEntry).value = value + c.order.MoveToFront(element) + return + } + entry := &cacheEntry{guild: key, value: value} + c.items[key] = c.order.PushFront(entry) + c.evictLocked() +} + +func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) { + c.mu.Lock() + defer c.mu.Unlock() + element, ok := c.channels[channelKey{channelID: channelID, userID: userID}] + if !ok { + return Resolved{}, false + } + c.order.MoveToFront(element) + return element.Value.(*cacheEntry).value, true +} + +func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) { + c.mu.Lock() + defer c.mu.Unlock() + key := channelKey{channelID: channelID, userID: userID} + if element, ok := c.channels[key]; ok { + element.Value.(*cacheEntry).value = value + c.order.MoveToFront(element) + return + } + entry := &cacheEntry{channel: key, value: value, isChan: true} + c.channels[key] = c.order.PushFront(entry) + c.evictLocked() +} + +// InvalidateGuild сбрасывает права всех пользователей сервера. +func (c *Cache) InvalidateGuild(guildID uint64) { + c.mu.Lock() + defer c.mu.Unlock() + for key, element := range c.items { + if key.guildID == guildID { + c.order.Remove(element) + delete(c.items, key) + } + } +} + +// InvalidateChannel сбрасывает кэш комнаты. +func (c *Cache) InvalidateChannel(channelID uint64) { + c.mu.Lock() + defer c.mu.Unlock() + for key, element := range c.channels { + if key.channelID == channelID { + c.order.Remove(element) + delete(c.channels, key) + } + } +} + +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.items = make(map[guildKey]*list.Element, c.capacity) + c.channels = make(map[channelKey]*list.Element, c.capacity) + c.order.Init() +} + +func (c *Cache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.order.Len() +} + +func (c *Cache) evictLocked() { + for c.order.Len() > c.capacity { + oldest := c.order.Back() + if oldest == nil { + return + } + entry := oldest.Value.(*cacheEntry) + if entry.isChan { + delete(c.channels, entry.channel) + } else { + delete(c.items, entry.guild) + } + c.order.Remove(oldest) + } +} diff --git a/internal/permissions/channel.go b/internal/permissions/channel.go new file mode 100644 index 0000000..8978df4 --- /dev/null +++ b/internal/permissions/channel.go @@ -0,0 +1,78 @@ +package permissions + +import ( + "context" + "fmt" +) + +// Channel вычисляет права пользователя в конкретной комнате: базовые права +// сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2). +func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) (Resolved, error) { + if cached, ok := c.cache.GetChannel(channelID, userID); ok { + cached.IsInstanceAdmin = instanceAdmin + return cached, nil + } + + resolved, err := c.Guild(ctx, guildID, userID, instanceAdmin) + if err != nil { + return Resolved{}, err + } + resolved.Channel = resolved.Guild + if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) { + c.cache.PutChannel(channelID, userID, resolved) + return resolved, nil + } + + overrides, err := c.source.ChannelOverrides(ctx, channelID) + if err != nil { + return Resolved{}, fmt.Errorf("load channel overrides: %w", err) + } + roles, err := c.source.GuildRoles(ctx, guildID) + if err != nil { + return Resolved{}, fmt.Errorf("load guild roles: %w", err) + } + memberRoleIDs, err := c.source.MemberRoleIDs(ctx, guildID, userID) + if err != nil { + return Resolved{}, fmt.Errorf("load member roles: %w", err) + } + + assigned := make(map[uint64]bool, len(memberRoleIDs)+1) + for _, roleID := range memberRoleIDs { + assigned[roleID] = true + } + defaultRoleID := uint64(0) + for _, role := range roles { + if role.IsDefault { + defaultRoleID = role.ID + assigned[role.ID] = true + break + } + } + + // Порядок как в AGENT.md 6.2: базовые права роли @user → оверрайды ролей → + // персональный оверрайд пользователя. + denied, allowed := Permission(0), Permission(0) + for _, override := range overrides { + if override.TargetType != "role" { + continue + } + if override.TargetID == defaultRoleID || assigned[override.TargetID] { + denied |= override.Deny + allowed |= override.Allow + } + } + computed := (resolved.Guild &^ denied) | allowed + + // Персональный deny обязан перебить ролевой allow, поэтому применяется + // к уже вычисленному результату. + var userDeny, userAllow Permission + for _, override := range overrides { + if override.TargetType == "user" && override.TargetID == userID { + userDeny |= override.Deny + userAllow |= override.Allow + } + } + resolved.Channel = (computed &^ userDeny) | userAllow + c.cache.PutChannel(channelID, userID, resolved) + return resolved, nil +} diff --git a/internal/permissions/integration_test.go b/internal/permissions/integration_test.go new file mode 100644 index 0000000..c16f48e --- /dev/null +++ b/internal/permissions/integration_test.go @@ -0,0 +1,255 @@ +package permissions_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "glchat/internal/database" + "glchat/internal/permissions" + "glchat/internal/source" + "glchat/internal/store" +) + +// newTestStore поднимает реальную SQLite со всеми миграциями: так проверяются +// и SQL-запросы, и связка store → permissions (AGENT.md §11.1). +func newTestStore(t *testing.T) *store.Store { + t.Helper() + db, err := database.Open(context.Background(), database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }) + return store.New(db) +} + +func createUser(t *testing.T, st *store.Store, username string) *store.User { + t.Helper() + user, err := st.CreateUser(context.Background(), store.CreateUserParams{ + Username: username, + DisplayName: username, + EmailEnc: "v1:encrypted-" + username, + EmailIndex: "index-" + username, + PasswordHash: "argon2id$test", + }) + if err != nil { + t.Fatalf("create user %s: %v", username, err) + } + return user +} + +// buildGuild создаёт сервер с ролями по умолчанию: «Администратор» и «Пользователь». +func buildGuild(t *testing.T, st *store.Store, owner *store.User) (*store.Guild, *store.Role, *store.Role) { + t.Helper() + ctx := context.Background() + guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Test Guild", OwnerID: owner.ID, IsMain: true}) + if err != nil { + t.Fatalf("create guild: %v", err) + } + adminRole, err := st.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100, + }) + if err != nil { + t.Fatalf("create admin role: %v", err) + } + userRole, err := st.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions), + Position: 0, IsDefault: true, Mentionable: true, + }) + if err != nil { + t.Fatalf("create default role: %v", err) + } + if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil { + t.Fatalf("add owner as member: %v", err) + } + return guild, adminRole, userRole +} + +func TestEngineAgainstRealDatabase(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + calculator := permissions.NewCalculator(source.New(st)) + + owner := createUser(t, st, "owner") + member := createUser(t, st, "member") + moderator := createUser(t, st, "moderator") + guild, adminRole, userRole := buildGuild(t, st, owner) + + for _, user := range []*store.User{member, moderator} { + if _, err := st.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil { + t.Fatalf("add member: %v", err) + } + } + moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Модератор", + Permissions: uint64(permissions.ViewGuild | permissions.ViewChannel | permissions.SendMessages | + permissions.ManageMessages | permissions.KickMembers), + Position: 50, + }) + if err != nil { + t.Fatalf("create moderator role: %v", err) + } + if err := st.AssignRole(ctx, guild.ID, moderator.ID, moderatorRole.ID); err != nil { + t.Fatalf("assign moderator role: %v", err) + } + + channel, err := st.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + private, err := st.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelText, Name: "приватный", + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + // Приватный канал: @user не видит, модератор — видит. + if err := st.SetChannelOverride(ctx, store.ChannelOverride{ + ChannelID: private.ID, TargetType: "role", TargetID: userRole.ID, + Deny: uint64(permissions.ViewChannel | permissions.ReadMessageHistory), + }); err != nil { + t.Fatalf("set override: %v", err) + } + if err := st.SetChannelOverride(ctx, store.ChannelOverride{ + ChannelID: private.ID, TargetType: "role", TargetID: moderatorRole.ID, + Allow: uint64(permissions.ViewChannel | permissions.ReadMessageHistory), + }); err != nil { + t.Fatalf("set moderator override: %v", err) + } + calculator.InvalidateAll() + + t.Run("member sees public channel only", func(t *testing.T) { + public, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !public.Can(permissions.SendMessages) { + t.Fatal("member must send messages in the public channel") + } + hidden, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false) + if err != nil { + t.Fatalf("private channel permissions: %v", err) + } + if hidden.CanViewChannel() { + t.Fatal("member must not see the private channel") + } + if err := permissions.RequireChannel(hidden, permissions.ViewChannel); !errors.Is(err, permissions.ErrDenied) { + t.Fatalf("RequireChannel = %v, want ErrDenied", err) + } + }) + + t.Run("moderator sees private channel and can moderate", func(t *testing.T) { + visible, err := calculator.Channel(ctx, guild.ID, private.ID, moderator.ID, false) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !visible.CanViewChannel() || !visible.Can(permissions.ReadMessageHistory) { + t.Fatal("moderator must see the private channel") + } + if !visible.Has(permissions.KickMembers) { + t.Fatal("moderator must keep guild-level KICK_MEMBERS") + } + if visible.Can(permissions.BanMembers) { + t.Fatal("moderator must not receive BAN_MEMBERS") + } + }) + + t.Run("owner has administrator everywhere", func(t *testing.T) { + resolved, err := calculator.Channel(ctx, guild.ID, private.ID, owner.ID, false) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !resolved.IsOwner || !resolved.Can(permissions.Administrator) { + t.Fatal("owner must have ADMINISTRATOR") + } + if !resolved.CanViewChannel() { + t.Fatal("owner must see any channel") + } + }) + + t.Run("instance admin bypasses privacy", func(t *testing.T) { + stranger := createUser(t, st, "instance-admin") + resolved, err := calculator.Channel(ctx, guild.ID, private.ID, stranger.ID, true) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !resolved.CanViewChannel() || !resolved.Can(permissions.ManageRoles) { + t.Fatal("instance admin must bypass privacy and permissions") + } + }) + + t.Run("timeout blocks writing", func(t *testing.T) { + timeout := store.TimeoutFromNow(1) + if err := st.UpdateGuildMember(ctx, guild.ID, member.ID, nil, &timeout); err != nil { + t.Fatalf("set timeout: %v", err) + } + calculator.InvalidateAll() + + resolved, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !resolved.TimedOut { + t.Fatal("timeout must be reflected in resolved permissions") + } + if resolved.Can(permissions.SendMessages) { + t.Fatal("timed out member must not write") + } + if !resolved.Can(permissions.ViewChannel) { + t.Fatal("timed out member must still read") + } + }) + + t.Run("administrator role ignores overrides", func(t *testing.T) { + if err := st.AssignRole(ctx, guild.ID, member.ID, adminRole.ID); err != nil { + t.Fatalf("assign admin role: %v", err) + } + calculator.InvalidateAll() + resolved, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false) + if err != nil { + t.Fatalf("channel permissions: %v", err) + } + if !resolved.CanViewChannel() { + t.Fatal("administrator role must ignore deny overrides") + } + }) +} + +func TestRoleHierarchyHelpersWithRealRoles(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + owner := createUser(t, st, "owner2") + guild, adminRole, userRole := buildGuild(t, st, owner) + + moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Модератор", Position: 50, + Permissions: uint64(permissions.KickMembers), + }) + if err != nil { + t.Fatalf("create role: %v", err) + } + + toData := func(role *store.Role) permissions.RoleData { + return permissions.RoleData{ID: role.ID, Permissions: permissions.Permission(role.Permissions), Position: role.Position} + } + if !permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(userRole), false) { + t.Fatal("moderator must outrank the default role") + } + if permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(adminRole), false) { + t.Fatal("moderator must not outrank the administrator role") + } + if !permissions.OutranksRole(nil, toData(adminRole), true) { + t.Fatal("owner must outrank every role") + } +} diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go new file mode 100644 index 0000000..fe374b6 --- /dev/null +++ b/internal/permissions/permissions.go @@ -0,0 +1,353 @@ +// Package permissions — ядро вычисления прав glchat (AGENT.md 6.2, 7.4). +// Любая проверка прав на сервере обязана идти через этот пакет. +package permissions + +import ( + "context" + "fmt" + "strings" +) + +// Permission — битовая маска прав (uint64, AGENT.md 6.2). +type Permission uint64 + +const ( + ViewGuild Permission = 1 << iota + ViewChannel + ReadMessageHistory + SendMessages + AttachFiles + EmbedLinks + AddReactions + UseExternalEmojis + MentionEveryone + ManageMessages + ConnectVoice + Speak + Video + ScreenShare + UseSoundboard + MuteMembers + DeafenMembers + MoveMembers + ManageChannels + ManageChannelStatus + ManageChannelDescription + ManageChannelBackground + ManageChannelPermissions + ManageGuild + ManageRoles + ManageEmojis + ManageSounds + UseWebhooks + ManageWebhooks + CreateInvites + KickMembers + BanMembers + TimeoutMembers + ViewAuditLog + ManageNicknames + ChangeNickname + Administrator +) + +// AllPermissions — маска всех прав. +const AllPermissions Permission = (1 << 37) - 1 + +var permissionNames = map[Permission]string{ + ViewGuild: "VIEW_GUILD", + ViewChannel: "VIEW_CHANNEL", + ReadMessageHistory: "READ_MESSAGE_HISTORY", + SendMessages: "SEND_MESSAGES", + AttachFiles: "ATTACH_FILES", + EmbedLinks: "EMBED_LINKS", + AddReactions: "ADD_REACTIONS", + UseExternalEmojis: "USE_EXTERNAL_EMOJIS", + MentionEveryone: "MENTION_EVERYONE", + ManageMessages: "MANAGE_MESSAGES", + ConnectVoice: "CONNECT_VOICE", + Speak: "SPEAK", + Video: "VIDEO", + ScreenShare: "SCREEN_SHARE", + UseSoundboard: "USE_SOUNDBOARD", + MuteMembers: "MUTE_MEMBERS", + DeafenMembers: "DEAFEN_MEMBERS", + MoveMembers: "MOVE_MEMBERS", + ManageChannels: "MANAGE_CHANNELS", + ManageChannelStatus: "MANAGE_CHANNEL_STATUS", + ManageChannelDescription: "MANAGE_CHANNEL_DESCRIPTION", + ManageChannelBackground: "MANAGE_CHANNEL_BACKGROUND", + ManageChannelPermissions: "MANAGE_CHANNEL_PERMISSIONS", + ManageGuild: "MANAGE_GUILD", + ManageRoles: "MANAGE_ROLES", + ManageEmojis: "MANAGE_EMOJIS", + ManageSounds: "MANAGE_SOUNDS", + UseWebhooks: "USE_WEBHOOKS", + ManageWebhooks: "MANAGE_WEBHOOKS", + CreateInvites: "CREATE_INVITES", + KickMembers: "KICK_MEMBERS", + BanMembers: "BAN_MEMBERS", + TimeoutMembers: "TIMEOUT_MEMBERS", + ViewAuditLog: "VIEW_AUDIT_LOG", + ManageNicknames: "MANAGE_NICKNAMES", + ChangeNickname: "CHANGE_NICKNAME", + Administrator: "ADMINISTRATOR", +} + +func (p Permission) Has(required Permission) bool { + return p&required == required +} + +// Any сообщает, что установлен хотя бы один бит из маски. +func (p Permission) Any(mask Permission) bool { + return p&mask != 0 +} + +func (p Permission) String() string { + if p == 0 { + return "NONE" + } + names := make([]string, 0, 4) + for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 { + if p&bit != 0 { + names = append(names, permissionNames[bit]) + } + } + return strings.Join(names, "|") +} + +// AllPermissionsList возвращает все права по порядку битов (для API и UI). +func AllPermissionsList() []Permission { + list := make([]Permission, 0, 37) + for bit := ViewGuild; bit <= Administrator; bit <<= 1 { + list = append(list, bit) + } + return list +} + +// Name возвращает строковое имя права. +func Name(permission Permission) string { + if name, ok := permissionNames[permission]; ok { + return name + } + return fmt.Sprintf("UNKNOWN_%d", uint64(permission)) +} + +// ByName ищет право по имени (нужно API и импорту шаблонов). +func ByName(name string) (Permission, bool) { + for permission, candidate := range permissionNames { + if candidate == strings.ToUpper(strings.TrimSpace(name)) { + return permission, true + } + } + return 0, false +} + +// DefaultUserPermissions — права роли «Пользователь» при создании сервера +// (AGENT.md 6.3). +const DefaultUserPermissions = ViewGuild | ViewChannel | ReadMessageHistory | + SendMessages | AttachFiles | AddReactions | ConnectVoice | Speak + +// Resolved — результат вычисления прав для контекста сервера и комнаты. +type Resolved struct { + // Guild — права на уровне сервера (без учёта оверрайдов комнаты). + Guild Permission + // Channel — права с учётом оверрайдов (только для конкретной комнаты). + Channel Permission + // IsOwner — пользователь владеет сервером: неявный ADMINISTRATOR (AGENT.md 6.2). + IsOwner bool + // IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19). + IsInstanceAdmin bool + // TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10). + TimedOut bool +} + +// Has — есть ли право на сервере. ADMINISTRATOR даёт все права (AGENT.md 6.2). +func (r Resolved) Has(permission Permission) bool { + if r.IsInstanceAdmin { + return true + } + if r.Guild.Has(Administrator) { + return true + } + return r.Guild.Has(permission) +} + +// Can — есть ли право в комнате (с учётом тайм-аута и оверрайдов). +func (r Resolved) Can(permission Permission) bool { + if r.IsInstanceAdmin { + return true + } + if r.Channel.Has(Administrator) { + return true + } + if !r.Channel.Has(permission) { + return false + } + if r.TimedOut && permission.Any(SendMessages|AddReactions|AttachFiles|Speak) { + return false + } + return true +} + +// CanViewChannel — может ли пользователь видеть комнату. Требуется именно +// VIEW_CHANNEL: CONNECT_VOICE сам по себе комнату в списке не показывает +// (текстовая комната без VIEW_CHANNEL скрыта, AGENT.md 7.5). +func (r Resolved) CanViewChannel() bool { + if r.IsInstanceAdmin { + return true + } + if r.Channel.Has(Administrator) { + return true + } + return r.Channel.Has(ViewChannel) +} + +// Source — данные для расчёта прав: роли сервера и роли участника. +type Source interface { + GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error) + MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) + ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error) + GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) + MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) +} + +// RoleData и OverrideData описывают входные данные расчёта без зависимости +// пакета прав от пакета хранения. +type RoleData struct { + ID uint64 + Permissions Permission + IsDefault bool + Position int +} + +type OverrideData struct { + TargetType string // role | user + TargetID uint64 + Allow Permission + Deny Permission +} + +// Calculator вычисляет права и кэширует результат (AGENT.md 9.1). +type Calculator struct { + source Source + cache *Cache +} + +func NewCalculator(source Source) *Calculator { + return &Calculator{source: source, cache: NewCache(4096)} +} + +// InvalidateGuild сбрасывает кэш сервера при изменении ролей или оверрайдов. +func (c *Calculator) InvalidateGuild(guildID uint64) { c.cache.InvalidateGuild(guildID) } + +// InvalidateChannel сбрасывает кэш комнаты. +func (c *Calculator) InvalidateChannel(channelID uint64) { c.cache.InvalidateChannel(channelID) } + +// InvalidateAll полностью очищает кэш (после импорта или массовых изменений). +func (c *Calculator) InvalidateAll() { c.cache.Clear() } + +// Guild вычисляет права пользователя на сервере. +func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instanceAdmin bool) (Resolved, error) { + if cached, ok := c.cache.GetGuild(guildID, userID); ok { + cached.IsInstanceAdmin = instanceAdmin + return cached, nil + } + + roles, err := c.source.GuildRoles(ctx, guildID) + if err != nil { + return Resolved{}, fmt.Errorf("load guild roles: %w", err) + } + memberRoleIDs, err := c.source.MemberRoleIDs(ctx, guildID, userID) + if err != nil { + return Resolved{}, fmt.Errorf("load member roles: %w", err) + } + ownerID, err := c.source.GuildOwnerID(ctx, guildID) + if err != nil { + return Resolved{}, fmt.Errorf("load guild owner: %w", err) + } + + resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin} + assigned := make(map[uint64]bool, len(memberRoleIDs)) + for _, roleID := range memberRoleIDs { + assigned[roleID] = true + } + + for _, role := range roles { + if role.IsDefault || assigned[role.ID] { + resolved.Guild |= role.Permissions + } + } + if resolved.IsOwner { + // Владелец сервера имеет ADMINISTRATOR неявно и не может его лишиться. + resolved.Guild |= Administrator + } + timedOut, err := c.source.MemberTimeout(ctx, guildID, userID) + if err != nil { + return Resolved{}, fmt.Errorf("load member timeout: %w", err) + } + resolved.TimedOut = timedOut + + c.cache.PutGuild(guildID, userID, resolved) + return resolved, nil +} + +// RequireGuild проверяет право на сервере и возвращает ошибку доступа. +func RequireGuild(resolved Resolved, permission Permission) error { + if !resolved.Has(permission) { + return fmt.Errorf("%w: %s", ErrDenied, Name(permission)) + } + return nil +} + +// RequireChannel проверяет право в комнате и возвращает ошибку доступа. +func RequireChannel(resolved Resolved, permission Permission) error { + if !resolved.Can(permission) { + return fmt.Errorf("%w: %s", ErrDenied, Name(permission)) + } + return nil +} + +// ErrDenied — единая ошибка отсутствия прав (на HTTP-слое → perm.denied). +var ErrDenied = fmt.Errorf("permission denied") + +// HigherThan сообщает, что роль a выше роли b (AGENT.md 7.4). +func HigherThan(a, b RoleData) bool { return a.Position > b.Position } + +// OutranksRole проверяет, что актор может управлять ролью: его высшая роль +// должна быть выше целевой, владелец — выше всех (AGENT.md 7.4). +func OutranksRole(actorRoles []RoleData, targetRole RoleData, isOwner bool) bool { + if isOwner { + return true + } + for _, role := range actorRoles { + if role.Position > targetRole.Position { + return true + } + } + return false +} + +// OutranksMember проверяет иерархию участников: нельзя управлять тем, у кого +// высшая роль не ниже роли актора (владелец — выше всех). +func OutranksMember(actorRoles, targetRoles []RoleData, actorIsOwner, targetIsOwner bool) bool { + if actorIsOwner { + return !targetIsOwner || actorIsOwner + } + if targetIsOwner { + return false + } + actorTop := topPosition(actorRoles) + targetTop := topPosition(targetRoles) + return actorTop > targetTop +} + +func topPosition(roles []RoleData) int { + top := 0 + for _, role := range roles { + if role.Position > top { + top = role.Position + } + } + return top +} diff --git a/internal/permissions/permissions_test.go b/internal/permissions/permissions_test.go new file mode 100644 index 0000000..295db75 --- /dev/null +++ b/internal/permissions/permissions_test.go @@ -0,0 +1,318 @@ +package permissions + +import ( + "context" + "errors" + "testing" +) + +// fakeSource — управляемый источник данных для тестов прав. +type fakeSource struct { + roles map[uint64][]RoleData + memberRoles map[uint64]map[uint64][]uint64 + overrides map[uint64][]OverrideData + owners map[uint64]uint64 + timeouts map[uint64]map[uint64]bool +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + roles: map[uint64][]RoleData{}, + memberRoles: map[uint64]map[uint64][]uint64{}, + overrides: map[uint64][]OverrideData{}, + owners: map[uint64]uint64{}, + timeouts: map[uint64]map[uint64]bool{}, + } +} + +func (f *fakeSource) GuildRoles(_ context.Context, guildID uint64) ([]RoleData, error) { + return f.roles[guildID], nil +} + +func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([]uint64, error) { + if byUser, ok := f.memberRoles[guildID]; ok { + return byUser[userID], nil + } + return nil, nil +} + +func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) { + return f.overrides[channelID], nil +} + +func (f *fakeSource) GuildOwnerID(_ context.Context, guildID uint64) (uint64, error) { + return f.owners[guildID], nil +} + +func (f *fakeSource) MemberTimeout(_ context.Context, guildID, userID uint64) (bool, error) { + if byUser, ok := f.timeouts[guildID]; ok { + return byUser[userID], nil + } + return false, nil +} + +const ( + guildID = uint64(100) + ownerID = uint64(1) + memberID = uint64(2) + adminID = uint64(3) + defaultID = uint64(10) + adminRole = uint64(11) + moderatorRole = uint64(12) + channelID = uint64(500) +) + +func fixture() (*fakeSource, *Calculator) { + source := newFakeSource() + source.roles[guildID] = []RoleData{ + {ID: adminRole, Permissions: Administrator, Position: 100}, + {ID: moderatorRole, Permissions: ManageMessages | KickMembers | ViewGuild | ViewChannel | SendMessages, Position: 50}, + {ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0}, + } + source.memberRoles[guildID] = map[uint64][]uint64{ + memberID: {}, + adminID: {adminRole}, + ownerID: {}, + } + source.owners[guildID] = ownerID + return source, NewCalculator(source) +} + +func TestDefaultRoleProvidesBasePermissions(t *testing.T) { + _, calculator := fixture() + resolved, err := calculator.Guild(context.Background(), guildID, memberID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if !resolved.Has(SendMessages) || !resolved.Has(ViewChannel) { + t.Fatalf("default role permissions not applied: %s", resolved.Guild) + } + if resolved.Has(KickMembers) { + t.Fatal("member must not receive moderator permissions") + } +} + +func TestAssignedRoleAddsPermissions(t *testing.T) { + source, calculator := fixture() + source.memberRoles[guildID][memberID] = []uint64{moderatorRole} + calculator.InvalidateAll() + + resolved, err := calculator.Guild(context.Background(), guildID, memberID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if !resolved.Has(KickMembers) || !resolved.Has(ManageMessages) { + t.Fatalf("assigned role permissions missing: %s", resolved.Guild) + } +} + +func TestAdministratorGrantsEverything(t *testing.T) { + _, calculator := fixture() + resolved, err := calculator.Guild(context.Background(), guildID, adminID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if !resolved.Has(Administrator) { + t.Fatal("administrator bit must be set") + } + if !resolved.Has(BanMembers) || !resolved.Has(ManageGuild) { + t.Fatal("ADMINISTRATOR must imply every permission") + } +} + +func TestOwnerAlwaysHasAdministrator(t *testing.T) { + source := newFakeSource() + // Владелец без ролей и без ADMINISTRATOR у роли @user. + source.roles[guildID] = []RoleData{{ID: defaultID, Permissions: ViewGuild, IsDefault: true}} + source.owners[guildID] = ownerID + calculator := NewCalculator(source) + + resolved, err := calculator.Guild(context.Background(), guildID, ownerID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if !resolved.IsOwner || !resolved.Has(Administrator) { + t.Fatal("owner must implicitly receive ADMINISTRATOR") + } +} + +func TestInstanceAdminBypassesEverything(t *testing.T) { + _, calculator := fixture() + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true) + if err != nil { + t.Fatalf("Channel: %v", err) + } + // Инстанс-админ обходит и приватность комнаты, и продуктовые лимиты (AGENT.md 7.19). + if !resolved.Can(BanMembers) || !resolved.CanViewChannel() { + t.Fatal("instance admin must bypass permission checks") + } + if err := RequireChannel(resolved, Administrator); err != nil { + t.Fatalf("instance admin must satisfy Administrator: %v", err) + } +} + +func TestChannelDenyOverrideRemovesPermission(t *testing.T) { + source, calculator := fixture() + source.overrides[channelID] = []OverrideData{ + {TargetType: "role", TargetID: defaultID, Deny: SendMessages}, + } + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if resolved.Can(SendMessages) { + t.Fatal("deny override must remove the permission") + } + if !resolved.Can(ViewChannel) { + t.Fatal("other permissions must survive the override") + } +} + +func TestChannelAllowOverrideGrantsPermission(t *testing.T) { + source, calculator := fixture() + source.overrides[channelID] = []OverrideData{ + {TargetType: "user", TargetID: memberID, Deny: ViewChannel}, + {TargetType: "role", TargetID: defaultID, Allow: ConnectVoice}, + } + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if resolved.Can(ViewChannel) { + t.Fatal("personal deny must hide the channel") + } + if !resolved.Can(ConnectVoice) { + t.Fatal("role allow must add the permission") + } +} + +func TestUserOverrideWinsOverRoleOverride(t *testing.T) { + source, calculator := fixture() + source.overrides[channelID] = []OverrideData{ + {TargetType: "role", TargetID: moderatorRole, Allow: SendMessages}, + {TargetType: "user", TargetID: memberID, Deny: SendMessages}, + } + source.memberRoles[guildID][memberID] = []uint64{moderatorRole} + calculator.InvalidateAll() + + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if resolved.Can(SendMessages) { + t.Fatal("user-level deny must be applied after role-level allow") + } +} + +func TestAdministratorIgnoresChannelOverrides(t *testing.T) { + source, calculator := fixture() + source.overrides[channelID] = []OverrideData{ + {TargetType: "role", TargetID: adminRole, Deny: ViewChannel | SendMessages}, + } + resolved, err := calculator.Channel(context.Background(), guildID, channelID, adminID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if !resolved.CanViewChannel() || !resolved.Can(SendMessages) { + t.Fatal("ADMINISTRATOR must ignore channel overrides") + } +} + +func TestTimeoutBlocksWritingButNotReading(t *testing.T) { + source, calculator := fixture() + source.timeouts[guildID] = map[uint64]bool{memberID: true} + calculator.InvalidateAll() + + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if resolved.Can(SendMessages) || resolved.Can(AddReactions) { + t.Fatal("timed out member must not be able to write") + } + if !resolved.Can(ViewChannel) || !resolved.Can(ReadMessageHistory) { + t.Fatal("timed out member must still read the channel") + } +} + +func TestRequireHelpersReturnDenied(t *testing.T) { + _, calculator := fixture() + resolved, err := calculator.Guild(context.Background(), guildID, memberID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if err := RequireGuild(resolved, SendMessages); err != nil { + t.Fatalf("RequireGuild(SendMessages): %v", err) + } + if err := RequireGuild(resolved, BanMembers); !errors.Is(err, ErrDenied) { + t.Fatalf("RequireGuild(BanMembers) = %v, want ErrDenied", err) + } +} + +func TestOutranksRoleAndMember(t *testing.T) { + top := RoleData{ID: adminRole, Position: 100} + middle := RoleData{ID: moderatorRole, Position: 50} + bottom := RoleData{ID: defaultID, Position: 0} + + if !OutranksRole([]RoleData{middle}, bottom, false) { + t.Fatal("moderator must outrank the default role") + } + if OutranksRole([]RoleData{middle}, top, false) { + t.Fatal("moderator must not outrank the administrator role") + } + if !OutranksRole(nil, top, true) { + t.Fatal("owner must outrank any role") + } + if !OutranksMember([]RoleData{top}, []RoleData{middle}, false, false) { + t.Fatal("higher role must outrank the lower member") + } + if OutranksMember([]RoleData{middle}, []RoleData{top}, false, false) { + t.Fatal("lower role must not manage the higher member") + } + if OutranksMember([]RoleData{top}, nil, false, true) { + t.Fatal("nobody except the owner may manage the owner") + } +} + +func TestPermissionNamesRoundTrip(t *testing.T) { + for _, permission := range AllPermissionsList() { + name := Name(permission) + parsed, ok := ByName(name) + if !ok || parsed != permission { + t.Fatalf("round trip failed for %s (%d)", name, permission) + } + } + if _, ok := ByName("NOT_A_PERMISSION"); ok { + t.Fatal("unknown permission name must not parse") + } + if len(AllPermissionsList()) != 37 { + t.Fatalf("expected 37 permissions, got %d", len(AllPermissionsList())) + } +} + +func TestCacheInvalidation(t *testing.T) { + source, calculator := fixture() + first, err := calculator.Guild(context.Background(), guildID, memberID, false) + if err != nil { + t.Fatalf("Guild: %v", err) + } + if first.Has(KickMembers) { + t.Fatal("member must not have KICK_MEMBERS initially") + } + + // Меняем роли в источнике без инвалидации: кэш должен отдать старое значение. + source.memberRoles[guildID][memberID] = []uint64{moderatorRole} + cached, _ := calculator.Guild(context.Background(), guildID, memberID, false) + if cached.Has(KickMembers) { + t.Fatal("cache must serve the stale value until invalidation") + } + + calculator.InvalidateGuild(guildID) + updated, err := calculator.Guild(context.Background(), guildID, memberID, false) + if err != nil { + t.Fatalf("Guild after invalidation: %v", err) + } + if !updated.Has(KickMembers) { + t.Fatal("after invalidation the new role must be visible") + } +} diff --git a/internal/source/permissions.go b/internal/source/permissions.go new file mode 100644 index 0000000..269c1cd --- /dev/null +++ b/internal/source/permissions.go @@ -0,0 +1,82 @@ +// Package source связывает пакет прав с хранилищем: реализует permissions.Source +// поверх store, чтобы движок прав не зависел от деталей доступа к данным. +package source + +import ( + "context" + "errors" + "time" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Adapter реализует permissions.Source. +type Adapter struct { + store *store.Store +} + +func New(st *store.Store) *Adapter { return &Adapter{store: st} } + +var _ permissions.Source = (*Adapter)(nil) + +func (a *Adapter) GuildRoles(ctx context.Context, guildID uint64) ([]permissions.RoleData, error) { + roles, err := a.store.ListGuildRoles(ctx, guildID) + if err != nil { + return nil, err + } + result := make([]permissions.RoleData, 0, len(roles)) + for _, role := range roles { + result = append(result, permissions.RoleData{ + ID: role.ID, + Permissions: permissions.Permission(role.Permissions), + IsDefault: role.IsDefault, + Position: role.Position, + }) + } + return result, nil +} + +func (a *Adapter) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) { + return a.store.MemberRoleIDs(ctx, guildID, userID) +} + +func (a *Adapter) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) { + overrides, err := a.store.ListChannelOverrides(ctx, channelID) + if err != nil { + return nil, err + } + result := make([]permissions.OverrideData, 0, len(overrides)) + for _, override := range overrides { + result = append(result, permissions.OverrideData{ + TargetType: override.TargetType, + TargetID: override.TargetID, + Allow: permissions.Permission(override.Allow), + Deny: permissions.Permission(override.Deny), + }) + } + return result, nil +} + +func (a *Adapter) GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) { + guild, err := a.store.GetGuild(ctx, guildID) + if err != nil { + return 0, err + } + return guild.OwnerID, nil +} + +func (a *Adapter) MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) { + member, err := a.store.GetGuildMember(ctx, guildID, userID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Не участник сервера: тайм-аут не применяется, правами рулит VIEW_GUILD. + return false, nil + } + return false, err + } + if member.TimeoutUntil == nil { + return false, nil + } + return member.TimeoutUntil.After(time.Now().UTC()), nil +} diff --git a/internal/store/channels.go b/internal/store/channels.go new file mode 100644 index 0000000..f693aa0 --- /dev/null +++ b/internal/store/channels.go @@ -0,0 +1,244 @@ +package store + +import ( + "context" + "database/sql" + "strings" + "time" +) + +type ChannelType string + +const ( + ChannelText ChannelType = "text" + ChannelVoice ChannelType = "voice" + ChannelCategory ChannelType = "category" + ChannelDM ChannelType = "dm" +) + +type Channel struct { + ID uint64 + GuildID *uint64 + Type ChannelType + Name string + Description string + Position int + ParentID *uint64 + SlowmodeSeconds int + NSFW bool + Background string + VoiceStatus string + UserLimit int + CreatedAt time.Time +} + +type ChannelOverride struct { + ChannelID uint64 + TargetType string // role | user + TargetID uint64 + Allow uint64 + Deny uint64 +} + +type CreateChannelParams struct { + ID uint64 + GuildID *uint64 + Type ChannelType + Name string + Description string + Position int + ParentID *uint64 + SlowmodeSeconds int + NSFW bool + UserLimit int +} + +const channelColumns = `id, guild_id, type, name, description, position, parent_id, + slowmode_seconds, nsfw, background, voice_status, user_limit, created_at` + +func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO channels (id, guild_id, type, name, description, position, parent_id, + slowmode_seconds, nsfw, user_limit, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + mustID(params.ID), nullableID(params.GuildID), string(params.Type), params.Name, + params.Description, params.Position, nullableID(params.ParentID), params.SlowmodeSeconds, + boolToInt(params.NSFW), params.UserLimit, s.Now()) + if err != nil { + return nil, err + } + return s.GetChannel(ctx, params.ID) +} + +func (s *Store) GetChannel(ctx context.Context, id uint64) (*Channel, error) { + return scanChannel(s.reader.QueryRowContext(ctx, `SELECT `+channelColumns+` FROM channels WHERE id = ?`, int64(id))) +} + +func (s *Store) ListGuildChannels(ctx context.Context, guildID uint64) ([]Channel, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+channelColumns+` FROM channels WHERE guild_id = ? ORDER BY position, id`, int64(guildID)) + if err != nil { + return nil, err + } + return collectChannels(rows) +} + +type UpdateChannelParams struct { + Name *string + Description *string + Position *int + ParentID *uint64 + ClearParent bool + SlowmodeSeconds *int + NSFW *bool + Background *string + VoiceStatus *string + UserLimit *int +} + +func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) { + sets := []string{} + args := []any{} + if params.Name != nil { + sets = append(sets, "name = ?") + args = append(args, *params.Name) + } + if params.Description != nil { + sets = append(sets, "description = ?") + args = append(args, *params.Description) + } + if params.Position != nil { + sets = append(sets, "position = ?") + args = append(args, *params.Position) + } + if params.ParentID != nil { + sets = append(sets, "parent_id = ?") + args = append(args, int64(*params.ParentID)) + } + if params.ClearParent { + sets = append(sets, "parent_id = NULL") + } + if params.SlowmodeSeconds != nil { + sets = append(sets, "slowmode_seconds = ?") + args = append(args, *params.SlowmodeSeconds) + } + if params.NSFW != nil { + sets = append(sets, "nsfw = ?") + args = append(args, boolToInt(*params.NSFW)) + } + if params.Background != nil { + sets = append(sets, "background = ?") + args = append(args, nullableString(*params.Background)) + } + if params.VoiceStatus != nil { + sets = append(sets, "voice_status = ?") + args = append(args, *params.VoiceStatus) + } + if params.UserLimit != nil { + sets = append(sets, "user_limit = ?") + args = append(args, *params.UserLimit) + } + if len(sets) == 0 { + return s.GetChannel(ctx, id) + } + idValue, err := idToInt(id) + if err != nil { + return nil, err + } + args = append(args, idValue) + result, err := s.writer.ExecContext(ctx, buildQuery(updateChannelTemplate, strings.Join(sets, ", ")), args...) + if err != nil { + return nil, err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetChannel(ctx, id) +} + +func (s *Store) DeleteChannel(ctx context.Context, id uint64) error { + idValue, err := idToInt(id) + if err != nil { + return err + } + _, err = s.writer.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, idValue) + return err +} + +// SetChannelOverride создаёт или заменяет оверрайд прав на комнату. +func (s *Store) SetChannelOverride(ctx context.Context, override ChannelOverride) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO channel_overrides (channel_id, target_type, target_id, allow, deny) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (channel_id, target_type, target_id) DO UPDATE SET + allow = excluded.allow, deny = excluded.deny`, + int64(override.ChannelID), override.TargetType, int64(override.TargetID), + int64(override.Allow), int64(override.Deny)) + return err +} + +func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]ChannelOverride, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT channel_id, target_type, target_id, allow, deny + FROM channel_overrides WHERE channel_id = ?`, int64(channelID)) + if err != nil { + return nil, err + } + defer rows.Close() + + overrides := make([]ChannelOverride, 0, 4) + for rows.Next() { + var ( + override ChannelOverride + allow, deny int64 + ) + if err := rows.Scan(&override.ChannelID, &override.TargetType, &override.TargetID, &allow, &deny); err != nil { + return nil, err + } + override.Allow = intToID(allow) + override.Deny = intToID(deny) + overrides = append(overrides, override) + } + return overrides, rows.Err() +} + +func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) { + var ( + channel Channel + guildID sql.NullInt64 + parentID sql.NullInt64 + nsfw int + background sql.NullString + createdAt string + ) + err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description, + &channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background, + &channel.VoiceStatus, &channel.UserLimit, &createdAt) + if err != nil { + return nil, mapError(err) + } + channel.GuildID = optionalID(guildID) + channel.ParentID = optionalID(parentID) + channel.NSFW = nsfw == 1 + if background.Valid { + channel.Background = background.String + } + channel.CreatedAt = parseTimestamp(createdAt) + return &channel, nil +} + +func collectChannels(rows *sql.Rows) ([]Channel, error) { + defer rows.Close() + channels := make([]Channel, 0, 8) + for rows.Next() { + channel, err := scanChannel(rows) + if err != nil { + return nil, err + } + channels = append(channels, *channel) + } + return channels, rows.Err() +} diff --git a/internal/store/guilds.go b/internal/store/guilds.go new file mode 100644 index 0000000..7276235 --- /dev/null +++ b/internal/store/guilds.go @@ -0,0 +1,359 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" +) + +type Guild struct { + ID uint64 + Name string + OwnerID uint64 + IconFileID *uint64 + BannerFileID *uint64 + SplashFileID *uint64 + AccentColor int64 + Description string + IsMain bool + IsDiscoverable bool + Limits map[string]int + CreatedAt time.Time +} + +type GuildMember struct { + GuildID uint64 + UserID uint64 + Nickname string + JoinedAt time.Time + TimeoutUntil *time.Time +} + +// CreateGuildParams — параметры создания сервера. Роли по умолчанию создаёт +// вызывающий код (bootstrap или создатель сервера), чтобы можно было +// применить шаблон оформления (AGENT.md 7.3). +type CreateGuildParams struct { + ID uint64 + Name string + OwnerID uint64 + IsMain bool + IsDiscoverable bool + Description string + AccentColor int64 + Limits map[string]int +} + +const guildColumns = `id, name, owner_id, icon_file_id, banner_file_id, splash_file_id, + accent_color, description, is_main, is_discoverable, limits_json, created_at` + +func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Guild, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + limits := "{}" + if len(params.Limits) > 0 { + if encoded, err := json.Marshal(params.Limits); err == nil { + limits = string(encoded) + } + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor, + boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, s.Now()) + if err != nil { + return nil, err + } + return s.GetGuild(ctx, params.ID) +} + +func (s *Store) GetGuild(ctx context.Context, id uint64) (*Guild, error) { + return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE id = ?`, int64(id))) +} + +func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) { + return scanGuild(s.reader.QueryRowContext(ctx, + `SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`)) +} + +type UpdateGuildParams struct { + Name *string + Description *string + AccentColor *int64 + IconFileID *uint64 + BannerFileID *uint64 + SplashFileID *uint64 + IsDiscoverable *bool + Limits map[string]int +} + +func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildParams) (*Guild, error) { + var ( + sets []string + args []any + ) + if params.Name != nil { + sets = append(sets, "name = ?") + args = append(args, *params.Name) + } + if params.Description != nil { + sets = append(sets, "description = ?") + args = append(args, *params.Description) + } + if params.AccentColor != nil { + sets = append(sets, "accent_color = ?") + args = append(args, *params.AccentColor) + } + if params.IconFileID != nil { + sets = append(sets, "icon_file_id = ?") + args = append(args, int64(*params.IconFileID)) + } + if params.BannerFileID != nil { + sets = append(sets, "banner_file_id = ?") + args = append(args, int64(*params.BannerFileID)) + } + if params.SplashFileID != nil { + sets = append(sets, "splash_file_id = ?") + args = append(args, int64(*params.SplashFileID)) + } + if params.IsDiscoverable != nil { + sets = append(sets, "is_discoverable = ?") + args = append(args, boolToInt(*params.IsDiscoverable)) + } + if params.Limits != nil { + encoded, err := json.Marshal(params.Limits) + if err != nil { + return nil, err + } + sets = append(sets, "limits_json = ?") + args = append(args, string(encoded)) + } + if len(sets) == 0 { + return s.GetGuild(ctx, id) + } + args = append(args, int64(id)) + result, err := s.writer.ExecContext(ctx, buildQuery(updateGuildTemplate, joinSets(sets)), args...) + if err != nil { + return nil, err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetGuild(ctx, id) +} + +func (s *Store) DeleteGuild(ctx context.Context, id uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, int64(id)) + return err +} + +// ListGuildsForUser возвращает серверы пользователя (участие). +func (s *Store) ListGuildsForUser(ctx context.Context, userID uint64) ([]Guild, error) { + rows, err := s.reader.QueryContext(ctx, buildQuery(selectGuildsForUserTemplate, prefixColumns("g", guildColumns)), int64(userID)) + if err != nil { + return nil, err + } + return collectGuilds(rows) +} + +// ListAllGuilds используется инстанс-админом (AGENT.md 7.19). +func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) { + rows, err := s.reader.QueryContext(ctx, `SELECT `+guildColumns+` FROM guilds ORDER BY id`) + if err != nil { + return nil, err + } + return collectGuilds(rows) +} + +func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count) + return count, err +} + +func (s *Store) CountGuildMembers(ctx context.Context, guildID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE guild_id = ?`, int64(guildID)).Scan(&count) + return count, err +} + +func (s *Store) AddGuildMember(ctx context.Context, guildID, userID uint64, nickname string) (*GuildMember, error) { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO guild_members (guild_id, user_id, nickname, joined_at) VALUES (?, ?, ?, ?) + ON CONFLICT (guild_id, user_id) DO NOTHING`, + int64(guildID), int64(userID), nullableString(nickname), s.Now()) + if err != nil { + return nil, err + } + return s.GetGuildMember(ctx, guildID, userID) +} + +func (s *Store) GetGuildMember(ctx context.Context, guildID, userID uint64) (*GuildMember, error) { + var ( + member GuildMember + nickname sql.NullString + joinedAt string + timeoutUntil sql.NullString + ) + err := s.reader.QueryRowContext(ctx, ` + SELECT guild_id, user_id, nickname, joined_at, timeout_until + FROM guild_members WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)). + Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil) + if err != nil { + return nil, mapError(err) + } + if nickname.Valid { + member.Nickname = nickname.String + } + member.JoinedAt = parseTimestamp(joinedAt) + if timeoutUntil.Valid { + value := parseTimestamp(timeoutUntil.String) + member.TimeoutUntil = &value + } + return &member, nil +} + +func (s *Store) ListGuildMembers(ctx context.Context, guildID uint64) ([]GuildMember, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT guild_id, user_id, nickname, joined_at, timeout_until + FROM guild_members WHERE guild_id = ? ORDER BY joined_at`, int64(guildID)) + if err != nil { + return nil, err + } + defer rows.Close() + + members := make([]GuildMember, 0, 16) + for rows.Next() { + var ( + member GuildMember + nickname sql.NullString + joinedAt string + timeoutUntil sql.NullString + ) + if err := rows.Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil); err != nil { + return nil, err + } + if nickname.Valid { + member.Nickname = nickname.String + } + member.JoinedAt = parseTimestamp(joinedAt) + if timeoutUntil.Valid { + value := parseTimestamp(timeoutUntil.String) + member.TimeoutUntil = &value + } + members = append(members, member) + } + return members, rows.Err() +} + +func (s *Store) UpdateGuildMember(ctx context.Context, guildID, userID uint64, nickname *string, timeoutUntil *time.Time) error { + sets := []string{} + args := []any{} + if nickname != nil { + sets = append(sets, "nickname = ?") + args = append(args, nullableString(*nickname)) + } + if timeoutUntil != nil { + if timeoutUntil.IsZero() { + sets = append(sets, "timeout_until = NULL") + } else { + sets = append(sets, "timeout_until = ?") + args = append(args, s.Timestamp(*timeoutUntil)) + } + } + if len(sets) == 0 { + return nil + } + args = append(args, int64(guildID), int64(userID)) + _, err := s.writer.ExecContext(ctx, buildQuery(updateGuildMemberTemplate, joinSets(sets)), args...) + return err +} + +// TimeoutFromNow возвращает момент окончания тайм-аута (AGENT.md 7.10). +func TimeoutFromNow(days int) time.Time { + if days < 1 { + days = 1 + } + return time.Now().UTC().AddDate(0, 0, days) +} + +func (s *Store) RemoveGuildMember(ctx context.Context, guildID, userID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?`, + int64(guildID), int64(userID)) + return err +} + +func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID uint64) error { + result, err := s.writer.ExecContext(ctx, `UPDATE guilds SET owner_id = ? WHERE id = ?`, + int64(newOwnerID), int64(guildID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) { + var ( + guild Guild + iconID sql.NullInt64 + bannerID sql.NullInt64 + splashID sql.NullInt64 + isMain int + discover int + limitsJSON string + createdAt string + ) + err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID, + &guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt) + if err != nil { + return nil, mapError(err) + } + guild.IconFileID = optionalID(iconID) + guild.BannerFileID = optionalID(bannerID) + guild.SplashFileID = optionalID(splashID) + guild.IsMain = isMain == 1 + guild.IsDiscoverable = discover == 1 + if limitsJSON != "" && limitsJSON != "{}" { + _ = json.Unmarshal([]byte(limitsJSON), &guild.Limits) + } + guild.CreatedAt = parseTimestamp(createdAt) + return &guild, nil +} + +func collectGuilds(rows *sql.Rows) ([]Guild, error) { + defer rows.Close() + guilds := make([]Guild, 0, 8) + for rows.Next() { + guild, err := scanGuild(rows) + if err != nil { + return nil, err + } + guilds = append(guilds, *guild) + } + return guilds, rows.Err() +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func joinSets(sets []string) string { + return strings.Join(sets, ", ") +} + +// prefixColumns добавляет префикс таблицы к списку колонок. +func prefixColumns(prefix, columns string) string { + parts := strings.Split(columns, ",") + for i, part := range parts { + parts[i] = prefix + "." + strings.TrimSpace(part) + } + return strings.Join(parts, ", ") +} diff --git a/internal/store/instance.go b/internal/store/instance.go new file mode 100644 index 0000000..deafbeb --- /dev/null +++ b/internal/store/instance.go @@ -0,0 +1,193 @@ +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 + } + 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 +} diff --git a/internal/store/roles.go b/internal/store/roles.go new file mode 100644 index 0000000..d11fc78 --- /dev/null +++ b/internal/store/roles.go @@ -0,0 +1,294 @@ +package store + +import ( + "context" + "database/sql" + "strings" + "time" +) + +type Role struct { + ID uint64 + GuildID uint64 + Name string + Color int64 + Position int + Permissions uint64 + IsDefault bool + Mentionable bool + Hoist bool + CosmeticFrameID *uint64 + CosmeticBadgeID *uint64 + NickColor *int64 + NickEffect string + CreatedAt time.Time +} + +// CreateRoleParams — параметры роли. Пермишены хранятся как в SQLite (int64), +// а в модели — uint64: конвертация в одном месте. +type CreateRoleParams struct { + ID uint64 + GuildID uint64 + Name string + Color int64 + Position int + Permissions uint64 + IsDefault bool + Mentionable bool + Hoist bool + NickEffect string +} + +const roleColumns = `id, guild_id, name, color, position, permissions, is_default, + mentionable, hoist, cosmetic_frame_id, cosmetic_badge_id, nick_color, nick_effect, created_at` + +func (s *Store) CreateRole(ctx context.Context, params CreateRoleParams) (*Role, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + if params.NickEffect == "" { + params.NickEffect = "none" + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO roles (id, guild_id, name, color, position, permissions, is_default, mentionable, hoist, nick_effect, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), int64(params.GuildID), params.Name, params.Color, params.Position, + int64(params.Permissions), boolToInt(params.IsDefault), boolToInt(params.Mentionable), + boolToInt(params.Hoist), params.NickEffect, s.Now()) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrConflict + } + return nil, err + } + return s.GetRole(ctx, params.ID) +} + +func (s *Store) GetRole(ctx context.Context, id uint64) (*Role, error) { + return scanRole(s.reader.QueryRowContext(ctx, `SELECT `+roleColumns+` FROM roles WHERE id = ?`, int64(id))) +} + +// ListGuildRoles возвращает роли сервера по убыванию позиции (AGENT.md 7.4). +func (s *Store) ListGuildRoles(ctx context.Context, guildID uint64) ([]Role, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+roleColumns+` FROM roles WHERE guild_id = ? ORDER BY position DESC, id`, int64(guildID)) + if err != nil { + return nil, err + } + defer rows.Close() + + roles := make([]Role, 0, 4) + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + return nil, err + } + roles = append(roles, *role) + } + return roles, rows.Err() +} + +// DefaultRole возвращает роль @user (is_default) — базу для вычисления прав. +func (s *Store) DefaultRole(ctx context.Context, guildID uint64) (*Role, error) { + return scanRole(s.reader.QueryRowContext(ctx, + `SELECT `+roleColumns+` FROM roles WHERE guild_id = ? AND is_default = 1 ORDER BY position LIMIT 1`, + int64(guildID))) +} + +type UpdateRoleParams struct { + Name *string + Color *int64 + Position *int + Permissions *uint64 + Mentionable *bool + Hoist *bool + CosmeticFrameID *uint64 + CosmeticBadgeID *uint64 + NickColor *int64 + NickEffect *string + ClearFrame bool + ClearBadge bool + ClearNickColor bool +} + +func (s *Store) UpdateRole(ctx context.Context, id uint64, params UpdateRoleParams) (*Role, error) { + sets := []string{} + args := []any{} + if params.Name != nil { + sets = append(sets, "name = ?") + args = append(args, *params.Name) + } + if params.Color != nil { + sets = append(sets, "color = ?") + args = append(args, *params.Color) + } + if params.Position != nil { + sets = append(sets, "position = ?") + args = append(args, *params.Position) + } + if params.Permissions != nil { + sets = append(sets, "permissions = ?") + args = append(args, int64(*params.Permissions)) + } + if params.Mentionable != nil { + sets = append(sets, "mentionable = ?") + args = append(args, boolToInt(*params.Mentionable)) + } + if params.Hoist != nil { + sets = append(sets, "hoist = ?") + args = append(args, boolToInt(*params.Hoist)) + } + if params.CosmeticFrameID != nil { + sets = append(sets, "cosmetic_frame_id = ?") + args = append(args, int64(*params.CosmeticFrameID)) + } + if params.ClearFrame { + sets = append(sets, "cosmetic_frame_id = NULL") + } + if params.CosmeticBadgeID != nil { + sets = append(sets, "cosmetic_badge_id = ?") + args = append(args, int64(*params.CosmeticBadgeID)) + } + if params.ClearBadge { + sets = append(sets, "cosmetic_badge_id = NULL") + } + if params.NickColor != nil { + sets = append(sets, "nick_color = ?") + args = append(args, *params.NickColor) + } + if params.ClearNickColor { + sets = append(sets, "nick_color = NULL") + } + if params.NickEffect != nil { + sets = append(sets, "nick_effect = ?") + args = append(args, *params.NickEffect) + } + if len(sets) == 0 { + return s.GetRole(ctx, id) + } + args = append(args, int64(id)) + if _, err := s.writer.ExecContext(ctx, buildQuery(updateRoleTemplate, strings.Join(sets, ", ")), args...); err != nil { + return nil, err + } + return s.GetRole(ctx, id) +} + +func (s *Store) DeleteRole(ctx context.Context, id uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, int64(id)) + return err +} + +// AssignRole выдаёт роль участнику (идемпотентно). +func (s *Store) AssignRole(ctx context.Context, guildID, userID, roleID uint64) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO member_roles (guild_id, user_id, role_id) VALUES (?, ?, ?) + ON CONFLICT DO NOTHING`, int64(guildID), int64(userID), int64(roleID)) + return err +} + +func (s *Store) RemoveRole(ctx context.Context, guildID, userID, roleID uint64) error { + _, err := s.writer.ExecContext(ctx, + `DELETE FROM member_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?`, + int64(guildID), int64(userID), int64(roleID)) + return err +} + +// MemberRoles возвращает роли участника сервера. +func (s *Store) MemberRoles(ctx context.Context, guildID, userID uint64) ([]Role, error) { + rows, err := s.reader.QueryContext(ctx, buildQuery(selectMemberRolesTemplate, prefixColumns("r", roleColumns)), + int64(guildID), int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + roles := make([]Role, 0, 4) + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + return nil, err + } + roles = append(roles, *role) + } + return roles, rows.Err() +} + +// MemberRoleIDs возвращает идентификаторы ролей участника (для READY). +func (s *Store) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT role_id FROM member_roles WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + ids := make([]uint64, 0, 4) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, uint64(id)) + } + return ids, rows.Err() +} + +// RoleMemberCount считает участников с ролью (нужно для защиты от потери +// управления сервером, AGENT.md 6.3). +func (s *Store) RoleMemberCount(ctx context.Context, roleID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM member_roles WHERE role_id = ?`, int64(roleID)).Scan(&count) + return count, err +} + +// GuildRolesWithManageRoles возвращает роли, дающие право управлять ролями. +func (s *Store) GuildRolesWithManageRoles(ctx context.Context, guildID uint64, permission uint64) ([]Role, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+roleColumns+` FROM roles + WHERE guild_id = ? AND (permissions & ?) <> 0`, int64(guildID), int64(permission)) + if err != nil { + return nil, err + } + defer rows.Close() + + roles := make([]Role, 0, 2) + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + return nil, err + } + roles = append(roles, *role) + } + return roles, rows.Err() +} + +func scanRole(scanner interface{ Scan(...any) error }) (*Role, error) { + var ( + role Role + permissions int64 + isDefault int + mentionable int + hoist int + frameID sql.NullInt64 + badgeID sql.NullInt64 + nickColor sql.NullInt64 + createdAt string + ) + err := scanner.Scan(&role.ID, &role.GuildID, &role.Name, &role.Color, &role.Position, &permissions, + &isDefault, &mentionable, &hoist, &frameID, &badgeID, &nickColor, &role.NickEffect, &createdAt) + if err != nil { + return nil, mapError(err) + } + role.Permissions = uint64(permissions) + role.IsDefault = isDefault == 1 + role.Mentionable = mentionable == 1 + role.Hoist = hoist == 1 + role.CosmeticFrameID = optionalID(frameID) + role.CosmeticBadgeID = optionalID(badgeID) + if nickColor.Valid { + value := nickColor.Int64 + role.NickColor = &value + } + role.CreatedAt = parseTimestamp(createdAt) + return &role, nil +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..c493b75 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// SessionTTL — время жизни сессии по умолчанию (AGENT.md 7.1). +const SessionTTL = 30 * 24 * time.Hour + +// StepUpWindow — окно свежей аутентификации для чувствительных действий. +const StepUpWindow = 10 * time.Minute + +type Session struct { + ID uint64 + UserID uint64 + TokenHash string + UserAgent string + IP string + CreatedAt time.Time + ExpiresAt time.Time + LastSeen time.Time + SteppedUpAt *time.Time +} + +// SteppedUp сообщает, что сессия прошла step-up недавно (AGENT.md 7.1). +func (s *Session) SteppedUp(now time.Time) bool { + if s.SteppedUpAt == nil { + return false + } + return now.Sub(*s.SteppedUpAt) < StepUpWindow +} + +type CreateSessionParams struct { + TokenHash string + UserAgent string + IP string + TTL time.Duration +} + +func (s *Store) CreateSession(ctx context.Context, userID uint64, params CreateSessionParams) (*Session, error) { + if params.TTL <= 0 { + params.TTL = SessionTTL + } + now := s.now() + expires := now.Add(params.TTL) + + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO sessions (id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + int64(s.NextID()), int64(userID), params.TokenHash, params.UserAgent, params.IP, + s.Timestamp(now), s.Timestamp(expires), s.Timestamp(now), + ) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrConflict + } + return nil, err + } + return s.GetSessionByTokenHash(ctx, params.TokenHash) +} + +func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error) { + row := s.reader.QueryRowContext(ctx, ` + SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at + FROM sessions WHERE token_hash = ?`, tokenHash) + return scanSession(row) +} + +func (s *Store) ListSessions(ctx context.Context, userID uint64) ([]Session, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at + FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_seen DESC`, + int64(userID), s.Now()) + if err != nil { + return nil, err + } + defer rows.Close() + + sessions := make([]Session, 0, 4) + for rows.Next() { + session, err := scanSession(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, *session) + } + return sessions, rows.Err() +} + +// RotateSession заменяет хэш токена, сохраняя идентификатор сессии +// (защита от session fixation, AGENT.md 7.1). +func (s *Store) RotateSession(ctx context.Context, sessionID uint64, tokenHash string) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE sessions SET token_hash = ?, last_seen = ? WHERE id = ?`, + tokenHash, s.Now(), int64(sessionID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// MarkSteppedUp фиксирует успешный step-up для сессии. +func (s *Store) MarkSteppedUp(ctx context.Context, sessionID uint64) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE sessions SET stepped_up_at = ?, last_seen = ? WHERE id = ?`, + s.Now(), s.Now(), int64(sessionID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) TouchSession(ctx context.Context, sessionID uint64) error { + _, err := s.writer.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`, s.Now(), int64(sessionID)) + return err +} + +func (s *Store) DeleteSession(ctx context.Context, sessionID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, int64(sessionID)) + return err +} + +// DeleteSessionsForUser отзывает все сессии пользователя: используется при +// смене пароля и «выйти везде» (AGENT.md 7.1). +func (s *Store) DeleteSessionsForUser(ctx context.Context, userID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID)) + return err +} + +// DeleteOtherSessions отзывает все сессии, кроме текущей. +func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id <> ?`, + int64(userID), int64(keepSessionID)) + return err +} + +// DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4). +func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) { + result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now()) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func scanSession(scanner interface{ Scan(...any) error }) (*Session, error) { + var ( + session Session + createdAt string + expiresAt string + lastSeen string + steppedUpAt sql.NullString + ) + err := scanner.Scan( + &session.ID, &session.UserID, &session.TokenHash, &session.UserAgent, &session.IP, + &createdAt, &expiresAt, &lastSeen, &steppedUpAt, + ) + if err != nil { + return nil, mapError(err) + } + session.CreatedAt = parseTimestamp(createdAt) + session.ExpiresAt = parseTimestamp(expiresAt) + session.LastSeen = parseTimestamp(lastSeen) + if steppedUpAt.Valid { + value := parseTimestamp(steppedUpAt.String) + session.SteppedUpAt = &value + } + return &session, nil +} diff --git a/internal/store/snowflake.go b/internal/store/snowflake.go new file mode 100644 index 0000000..009480a --- /dev/null +++ b/internal/store/snowflake.go @@ -0,0 +1,67 @@ +package store + +import ( + "sync" + "time" +) + +// Snowflake — генератор сортируемых идентификаторов (AGENT.md 6.4): +// 41 бит времени от эпохи 2026-01-01T00:00:00Z, 10 бит узла, 12 бит счётчика. +type Snowflake struct { + mu sync.Mutex + epoch int64 + node int64 + lastMs int64 + sequence int64 + now func() time.Time +} + +const ( + snowflakeEpochMs = int64(1767225600000) // 2026-01-01T00:00:00Z + nodeBits = 10 + sequenceBits = 12 + maxNode = (1 << nodeBits) - 1 + maxSequence = (1 << sequenceBits) - 1 +) + +func NewSnowflake(node uint16) *Snowflake { + return &Snowflake{ + epoch: snowflakeEpochMs, + node: int64(node) & maxNode, + now: func() time.Time { return time.Now().UTC() }, + } +} + +// Next возвращает монотонно возрастающий идентификатор. +func (s *Snowflake) Next() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + + nowMs := s.now().UnixMilli() + if nowMs < s.lastMs { + // Часы ушли назад: продолжаем с последней метки, чтобы не выдать дубль. + nowMs = s.lastMs + } + if nowMs == s.lastMs { + s.sequence = (s.sequence + 1) & maxSequence + if s.sequence == 0 { + // Счётчик переполнен в эту миллисекунду — ждём следующую. + for nowMs <= s.lastMs { + time.Sleep(time.Millisecond) + nowMs = s.now().UnixMilli() + } + } + } else { + s.sequence = 0 + } + s.lastMs = nowMs + + id := ((nowMs - s.epoch) << (nodeBits + sequenceBits)) | (s.node << sequenceBits) | s.sequence + return uint64(id) +} + +// SnowflakeTime возвращает время создания идентификатора. +func SnowflakeTime(id uint64) time.Time { + ms := int64(id>>(nodeBits+sequenceBits)) + snowflakeEpochMs + return time.UnixMilli(ms).UTC() +} diff --git a/internal/store/sql.go b/internal/store/sql.go new file mode 100644 index 0000000..2afff2b --- /dev/null +++ b/internal/store/sql.go @@ -0,0 +1,29 @@ +package store + +import "fmt" + +// SQL-шаблоны, которые собираются из заранее известных фрагментов +// (имена колонок заданы в коде, значения всегда идут параметрами). +// Конкатенация через fmt.Sprintf здесь безопасна и проверяется тестами. + +const updateGuildTemplate = `UPDATE guilds SET %s WHERE id = ?` + +const selectGuildsForUserTemplate = `SELECT %s + FROM guilds g JOIN guild_members m ON m.guild_id = g.id + WHERE m.user_id = ? ORDER BY g.id` + +const updateChannelTemplate = `UPDATE channels SET %s WHERE id = ?` + +const updateGuildMemberTemplate = `UPDATE guild_members SET %s WHERE guild_id = ? AND user_id = ?` + +const selectMemberRolesTemplate = `SELECT %s + FROM member_roles mr JOIN roles r ON r.id = mr.role_id + WHERE mr.guild_id = ? AND mr.user_id = ? + ORDER BY r.position DESC, r.id` + +// buildQuery подставляет фрагмент SQL в шаблон. +func buildQuery(template string, fragment string) string { + return fmt.Sprintf(template, fragment) +} + +const updateRoleTemplate = `UPDATE roles SET %s WHERE id = ?` diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..af9d94f --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,112 @@ +// Package store содержит доступ к данным glchat: модели и запросы поверх SQLite. +// Вся работа с БД идёт только через этот пакет (AGENT.md §12). +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "time" + + "glchat/internal/database" +) + +var ( + ErrNotFound = errors.New("record not found") + ErrConflict = errors.New("record already exists") + ErrPermissionDenied = errors.New("permission denied") + ErrLastAdminRole = errors.New("guild must keep a role with MANAGE_ROLES") + ErrLastAdministrator = errors.New("guild must keep administrator permissions") +) + +// Store — точка доступа к данным. Writer используется для мутаций, +// Reader — для чтения (один писатель и пул чтения, AGENT.md §9.1). +type Store struct { + writer *sql.DB + reader *sql.DB + ids *Snowflake + now func() time.Time +} + +func New(db *database.DB) *Store { + return &Store{ + writer: db.Writer, + reader: db.Reader, + ids: NewSnowflake(0), + now: func() time.Time { return time.Now().UTC() }, + } +} + +// Reader возвращает соединение для чтения (доступно другим пакетам, +// которым нужны собственные запросы, например permissions). +func (s *Store) Reader() *sql.DB { return s.reader } + +// Writer возвращает соединение для записи. +func (s *Store) Writer() *sql.DB { return s.writer } + +// NextID выдаёт новый идентификатор (Snowflake, AGENT.md 6.4). +func (s *Store) NextID() uint64 { return s.ids.Next() } + +// Timestamp форматирует время в ISO 8601 UTC с миллисекундами. +func (s *Store) Timestamp(t time.Time) string { + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} + +func (s *Store) Now() string { return s.Timestamp(s.now()) } + +// InTx выполняет функцию в транзакции с откатом при ошибке. +func (s *Store) InTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + if err := fn(tx); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + return fmt.Errorf("%w (rollback: %w)", err, rollbackErr) + } + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +func mapError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err +} + +// idToInt переводит uint64-идентификатор в int64 для SQLite. Идентификаторы +// Snowflake формируются приложением, поэтому заведомо меньше MaxInt64; +// проверка диапазона сохраняет корректность при ручных данных. +func idToInt(id uint64) (int64, error) { + if id > math.MaxInt64 { + return 0, fmt.Errorf("identifier %d exceeds the supported range", id) + } + return int64(id), nil +} + +// intToID переводит значение SQLite в uint64-идентификатор. +func intToID(value int64) uint64 { + if value < 0 { + return 0 + } + return uint64(value) +} + +// boolToInt превращает Go-флаг в значение SQLite (0/1). +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +// mustID конвертирует идентификатор, который уже проверен вызывающим кодом +// (значения Snowflake всегда в диапазоне int64). +func mustID(id uint64) int64 { return int64(id) } diff --git a/internal/store/totp.go b/internal/store/totp.go new file mode 100644 index 0000000..169e15f --- /dev/null +++ b/internal/store/totp.go @@ -0,0 +1,155 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "time" +) + +// TOTPSecret — секрет второго фактора: в БД лежит зашифрованным (AGENT.md 9.2), +// резервные коды хранятся только хэшами. +type TOTPSecret struct { + UserID uint64 + SecretEncrypted string + Enabled bool + RecoveryCodeHashs []string + ConfirmedAt *time.Time + CreatedAt time.Time +} + +// UpsertTOTPSecret создаёт или заменяет неподтверждённый секрет. +func (s *Store) UpsertTOTPSecret(ctx context.Context, userID uint64, secretEncrypted string) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO totp_secrets (user_id, secret_encrypted, enabled, recovery_codes_json, created_at) + VALUES (?, ?, 0, '[]', ?) + ON CONFLICT (user_id) DO UPDATE SET + secret_encrypted = excluded.secret_encrypted, + enabled = 0, + recovery_codes_json = '[]', + confirmed_at = NULL, + created_at = excluded.created_at`, + int64(userID), secretEncrypted, s.Now()) + return err +} + +func (s *Store) GetTOTPSecret(ctx context.Context, userID uint64) (*TOTPSecret, error) { + var ( + secret TOTPSecret + enabled int + codes string + confirmedAt sql.NullString + createdAt string + ) + err := s.reader.QueryRowContext(ctx, ` + SELECT user_id, secret_encrypted, enabled, recovery_codes_json, confirmed_at, created_at + FROM totp_secrets WHERE user_id = ?`, int64(userID)). + Scan(&secret.UserID, &secret.SecretEncrypted, &enabled, &codes, &confirmedAt, &createdAt) + if err != nil { + return nil, mapError(err) + } + secret.Enabled = enabled == 1 + if err := json.Unmarshal([]byte(codes), &secret.RecoveryCodeHashs); err != nil { + secret.RecoveryCodeHashs = nil + } + if confirmedAt.Valid { + value := parseTimestamp(confirmedAt.String) + secret.ConfirmedAt = &value + } + secret.CreatedAt = parseTimestamp(createdAt) + return &secret, nil +} + +// EnableTOTP подтверждает секрет и сохраняет хэши резервных кодов. +func (s *Store) EnableTOTP(ctx context.Context, userID uint64, recoveryCodeHashes []string) error { + encoded, err := json.Marshal(recoveryCodeHashes) + if err != nil { + return err + } + result, err := s.writer.ExecContext(ctx, ` + UPDATE totp_secrets SET enabled = 1, recovery_codes_json = ?, confirmed_at = ? + WHERE user_id = ?`, string(encoded), s.Now(), int64(userID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// ConsumeRecoveryCode удаляет использованный резервный код. +func (s *Store) ConsumeRecoveryCode(ctx context.Context, userID uint64, remaining []string) error { + encoded, err := json.Marshal(remaining) + if err != nil { + return err + } + _, err = s.writer.ExecContext(ctx, `UPDATE totp_secrets SET recovery_codes_json = ? WHERE user_id = ?`, + string(encoded), int64(userID)) + return err +} + +func (s *Store) DeleteTOTPSecret(ctx context.Context, userID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = ?`, int64(userID)) + return err +} + +// RecordSecurityEvent пишет событие безопасности (AGENT.md 6.1). +func (s *Store) RecordSecurityEvent(ctx context.Context, userID *uint64, eventType, ip, userAgent, metadata string) error { + var userValue any + if userID != nil { + userValue = int64(*userID) + } + if metadata == "" { + metadata = "{}" + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO security_events (id, user_id, type, ip, user_agent, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + int64(s.NextID()), userValue, eventType, ip, userAgent, metadata, s.Now()) + return err +} + +// ListSecurityEvents возвращает последние события пользователя. +func (s *Store) ListSecurityEvents(ctx context.Context, userID uint64, limit int) ([]SecurityEvent, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT id, user_id, type, ip, user_agent, metadata_json, created_at + FROM security_events WHERE user_id = ? ORDER BY id DESC LIMIT ?`, int64(userID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]SecurityEvent, 0, limit) + for rows.Next() { + var ( + event SecurityEvent + userValue sql.NullInt64 + createdAt string + metadataRaw string + ) + if err := rows.Scan(&event.ID, &userValue, &event.Type, &event.IP, &event.UserAgent, &metadataRaw, &createdAt); err != nil { + return nil, err + } + if userValue.Valid { + event.UserID = uint64(userValue.Int64) + } + event.Metadata = json.RawMessage(metadataRaw) + event.CreatedAt = parseTimestamp(createdAt) + events = append(events, event) + } + return events, rows.Err() +} + +type SecurityEvent struct { + ID uint64 + UserID uint64 + Type string + IP string + UserAgent string + Metadata json.RawMessage + CreatedAt time.Time +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..ce8207f --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,273 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" +) + +// User — аккаунт пользователя. Email в БД лежит зашифрованным (AGENT.md 9.2), +// поэтому в модели он заполняется только тогда, когда реально расшифрован. +type User struct { + ID uint64 + Username string + DisplayName string + Email string + PasswordHash string + AvatarFileID *uint64 + BannerFileID *uint64 + Bio string + Status string + CustomStatus string + CustomStatusEmoji string + Flags int64 + IsInstanceAdmin bool + Badges []string + Locale string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +// CreateUserParams — данные новой учётной записи: шифрование и blind index +// выполняет вызывающий код (internal/auth), чтобы store не знал про ключи. +type CreateUserParams struct { + ID uint64 + Username string + DisplayName string + EmailEnc string + EmailIndex string + PasswordHash string + Locale string +} + +const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id, + banner_file_id, bio, status, custom_status, custom_status_emoji, flags, + is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at` + +func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + if params.DisplayName == "" { + params.DisplayName = params.Username + } + if params.Locale == "" { + params.Locale = "ru" + } + ts := s.Now() + + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index, + password_hash, locale, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), params.Username, strings.ToLower(params.Username), params.DisplayName, + params.EmailEnc, params.EmailIndex, params.PasswordHash, params.Locale, ts, ts, + ) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrConflict + } + return nil, err + } + return s.GetUser(ctx, params.ID) +} + +func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id)) + return scanUser(row) +} + +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`, + strings.ToLower(username)) + return scanUser(row) +} + +// GetUserByEmailIndex ищет пользователя по blind index: сам email в запросе +// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2). +func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex) + return scanUser(row) +} + +// EncryptedEmail возвращает сохранённый шифротекст email для расшифровки. +func (s *Store) EncryptedEmail(ctx context.Context, id uint64) (string, error) { + var encrypted string + err := s.reader.QueryRowContext(ctx, `SELECT email_enc FROM users WHERE id = ?`, int64(id)).Scan(&encrypted) + if err != nil { + return "", mapError(err) + } + return encrypted, nil +} + +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var count int + if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +type UpdateUserParams struct { + DisplayName *string + Bio *string + Status *string + CustomStatus *string + CustomStatusEmoji *string + AvatarFileID *uint64 + BannerFileID *uint64 + Locale *string +} + +func (s *Store) UpdateUser(ctx context.Context, id uint64, params UpdateUserParams) (*User, error) { + var ( + sets []string + args []any + ) + add := func(column string, value any) { + sets = append(sets, column+" = ?") + args = append(args, value) + } + if params.DisplayName != nil { + add("display_name", *params.DisplayName) + } + if params.Bio != nil { + add("bio", *params.Bio) + } + if params.Status != nil { + add("status", *params.Status) + } + if params.CustomStatus != nil { + add("custom_status", *params.CustomStatus) + } + if params.CustomStatusEmoji != nil { + add("custom_status_emoji", *params.CustomStatusEmoji) + } + if params.AvatarFileID != nil { + add("avatar_file_id", int64(*params.AvatarFileID)) + } + if params.BannerFileID != nil { + add("banner_file_id", int64(*params.BannerFileID)) + } + if params.Locale != nil { + add("locale", *params.Locale) + } + if len(sets) == 0 { + return s.GetUser(ctx, id) + } + add("updated_at", s.Now()) + args = append(args, int64(id)) + + result, err := s.writer.ExecContext(ctx, `UPDATE users SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...) + if err != nil { + return nil, err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetUser(ctx, id) +} + +// UpdateUserPassword меняет хэш пароля и возвращает прежнее значение, чтобы +// вызывающий код мог отозвать все сессии (AGENT.md 7.1). +func (s *Store) UpdateUserPassword(ctx context.Context, id uint64, passwordHash string) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + passwordHash, s.Now(), int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) SetInstanceAdmin(ctx context.Context, id uint64, admin bool) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE users SET is_instance_admin = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + boolToInt(admin), s.Now(), int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) error { + encoded, err := json.Marshal(badges) + if err != nil { + return err + } + result, err := s.writer.ExecContext(ctx, + `UPDATE users SET badges_json = ?, updated_at = ? WHERE id = ?`, + string(encoded), s.Now(), int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { + var ( + user User + avatarID sql.NullInt64 + bannerID sql.NullInt64 + isAdmin int + badges string + createdAt string + updatedAt string + deletedAt sql.NullString + emailEncrypted string + ) + err := scanner.Scan( + &user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash, + &avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji, + &user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt, + ) + if err != nil { + return nil, mapError(err) + } + if avatarID.Valid { + value := uint64(avatarID.Int64) + user.AvatarFileID = &value + } + if bannerID.Valid { + value := uint64(bannerID.Int64) + user.BannerFileID = &value + } + user.IsInstanceAdmin = isAdmin == 1 + if err := json.Unmarshal([]byte(badges), &user.Badges); err != nil { + user.Badges = nil + } + user.CreatedAt = parseTimestamp(createdAt) + user.UpdatedAt = parseTimestamp(updatedAt) + if deletedAt.Valid { + value := parseTimestamp(deletedAt.String) + user.DeletedAt = &value + } + return &user, nil +} + +func parseTimestamp(value string) time.Time { + for _, layout := range []string{"2006-01-02T15:04:05.000Z", time.RFC3339Nano, time.RFC3339} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.UTC() + } + } + return time.Time{} +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint") +}