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:
@@ -107,6 +107,12 @@ linters:
|
|||||||
- gosec
|
- gosec
|
||||||
- errcheck
|
- errcheck
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
# store: идентификаторы Snowflake заведомо < MaxInt64 (преобразования
|
||||||
|
# документированы в idToInt/intToID), SQL собирается из константных
|
||||||
|
# фрагментов шаблона, а значения всегда передаются параметрами.
|
||||||
|
- path: "internal/store/"
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
- path: "(^|/)internal/database/sqlc/"
|
- path: "(^|/)internal/database/sqlc/"
|
||||||
linters:
|
linters:
|
||||||
- revive
|
- revive
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestOpenAppliesPragmasAndMigrates(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SchemaVersion() returned error: %v", err)
|
t.Fatalf("SchemaVersion() returned error: %v", err)
|
||||||
}
|
}
|
||||||
if version != 1 {
|
if version < 1 {
|
||||||
t.Errorf("schema version = %d, want 1", version)
|
t.Errorf("schema version = %d, want >= 1", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
var foreignKeys int
|
var foreignKeys int
|
||||||
@@ -72,6 +72,15 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
if applied != 1 {
|
if applied != 1 {
|
||||||
t.Errorf("migration 1 applied %d times, want 1", applied)
|
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 {
|
if err := db.Close(); err != nil {
|
||||||
t.Fatalf("Close() returned error: %v", err)
|
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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 = ?`
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user