Files
glchat/internal/store/guilds.go
T
grendervill a5205cc054 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 из константных шаблонов)
2026-09-19 21:24:17 +03:00

360 lines
10 KiB
Go

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, ", ")
}