feat(store,permissions): схема Фазы 1, доступ к данным и движок прав

- миграция 00002: users, sessions, totp_secrets, webauthn_credentials,
  security_events, guilds, guild_members, roles, member_roles, channels,
  channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1)
- internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий
  (ротация, step-up, logout-all), TOTP и события безопасности, серверы,
  участники, роли, комнаты и оверрайды, настройки инстанса и аудит
- internal/permissions: 37 прав битмаской, вычисление по правилам §6.2
  (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR),
  иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш
  с инвалидацией
- internal/source: адаптер permissions.Source поверх store
- тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite
  (приватная комната, модератор, владелец, инстанс-админ, тайм-аут)
- golangci: обоснованное исключение gosec для пакета store (конверсии
  Snowflake и сборка SQL из константных шаблонов)
This commit is contained in:
2026-09-19 21:24:17 +03:00
parent 06e313dcc6
commit a5205cc054
19 changed files with 3344 additions and 2 deletions
+6
View File
@@ -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
+11 -2
View File
@@ -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)
}
@@ -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;
+149
View File
@@ -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)
}
}
+78
View File
@@ -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
}
+255
View File
@@ -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")
}
}
+353
View File
@@ -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
}
+318
View File
@@ -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")
}
}
+82
View File
@@ -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
}
+244
View File
@@ -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()
}
+359
View File
@@ -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, ", ")
}
+193
View File
@@ -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
}
+294
View File
@@ -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
}
+177
View File
@@ -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
}
+67
View File
@@ -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()
}
+29
View File
@@ -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 = ?`
+112
View File
@@ -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) }
+155
View File
@@ -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
}
+273
View File
@@ -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")
}