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:
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user