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

- миграция 00002: users, sessions, totp_secrets, webauthn_credentials,
  security_events, guilds, guild_members, roles, member_roles, channels,
  channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1)
- internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий
  (ротация, step-up, logout-all), TOTP и события безопасности, серверы,
  участники, роли, комнаты и оверрайды, настройки инстанса и аудит
- internal/permissions: 37 прав битмаской, вычисление по правилам §6.2
  (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR),
  иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш
  с инвалидацией
- internal/source: адаптер permissions.Source поверх store
- тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite
  (приватная комната, модератор, владелец, инстанс-админ, тайм-аут)
- golangci: обоснованное исключение gosec для пакета store (конверсии
  Snowflake и сборка SQL из константных шаблонов)
This commit is contained in:
2026-09-19 21:24:17 +03:00
parent 06e313dcc6
commit a5205cc054
19 changed files with 3344 additions and 2 deletions
+244
View File
@@ -0,0 +1,244 @@
package store
import (
"context"
"database/sql"
"strings"
"time"
)
type ChannelType string
const (
ChannelText ChannelType = "text"
ChannelVoice ChannelType = "voice"
ChannelCategory ChannelType = "category"
ChannelDM ChannelType = "dm"
)
type Channel struct {
ID uint64
GuildID *uint64
Type ChannelType
Name string
Description string
Position int
ParentID *uint64
SlowmodeSeconds int
NSFW bool
Background string
VoiceStatus string
UserLimit int
CreatedAt time.Time
}
type ChannelOverride struct {
ChannelID uint64
TargetType string // role | user
TargetID uint64
Allow uint64
Deny uint64
}
type CreateChannelParams struct {
ID uint64
GuildID *uint64
Type ChannelType
Name string
Description string
Position int
ParentID *uint64
SlowmodeSeconds int
NSFW bool
UserLimit int
}
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, background, voice_status, user_limit, created_at`
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO channels (id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, user_limit, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
mustID(params.ID), nullableID(params.GuildID), string(params.Type), params.Name,
params.Description, params.Position, nullableID(params.ParentID), params.SlowmodeSeconds,
boolToInt(params.NSFW), params.UserLimit, s.Now())
if err != nil {
return nil, err
}
return s.GetChannel(ctx, params.ID)
}
func (s *Store) GetChannel(ctx context.Context, id uint64) (*Channel, error) {
return scanChannel(s.reader.QueryRowContext(ctx, `SELECT `+channelColumns+` FROM channels WHERE id = ?`, int64(id)))
}
func (s *Store) ListGuildChannels(ctx context.Context, guildID uint64) ([]Channel, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT `+channelColumns+` FROM channels WHERE guild_id = ? ORDER BY position, id`, int64(guildID))
if err != nil {
return nil, err
}
return collectChannels(rows)
}
type UpdateChannelParams struct {
Name *string
Description *string
Position *int
ParentID *uint64
ClearParent bool
SlowmodeSeconds *int
NSFW *bool
Background *string
VoiceStatus *string
UserLimit *int
}
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
sets := []string{}
args := []any{}
if params.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *params.Name)
}
if params.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *params.Description)
}
if params.Position != nil {
sets = append(sets, "position = ?")
args = append(args, *params.Position)
}
if params.ParentID != nil {
sets = append(sets, "parent_id = ?")
args = append(args, int64(*params.ParentID))
}
if params.ClearParent {
sets = append(sets, "parent_id = NULL")
}
if params.SlowmodeSeconds != nil {
sets = append(sets, "slowmode_seconds = ?")
args = append(args, *params.SlowmodeSeconds)
}
if params.NSFW != nil {
sets = append(sets, "nsfw = ?")
args = append(args, boolToInt(*params.NSFW))
}
if params.Background != nil {
sets = append(sets, "background = ?")
args = append(args, nullableString(*params.Background))
}
if params.VoiceStatus != nil {
sets = append(sets, "voice_status = ?")
args = append(args, *params.VoiceStatus)
}
if params.UserLimit != nil {
sets = append(sets, "user_limit = ?")
args = append(args, *params.UserLimit)
}
if len(sets) == 0 {
return s.GetChannel(ctx, id)
}
idValue, err := idToInt(id)
if err != nil {
return nil, err
}
args = append(args, idValue)
result, err := s.writer.ExecContext(ctx, buildQuery(updateChannelTemplate, strings.Join(sets, ", ")), args...)
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetChannel(ctx, id)
}
func (s *Store) DeleteChannel(ctx context.Context, id uint64) error {
idValue, err := idToInt(id)
if err != nil {
return err
}
_, err = s.writer.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, idValue)
return err
}
// SetChannelOverride создаёт или заменяет оверрайд прав на комнату.
func (s *Store) SetChannelOverride(ctx context.Context, override ChannelOverride) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO channel_overrides (channel_id, target_type, target_id, allow, deny)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (channel_id, target_type, target_id) DO UPDATE SET
allow = excluded.allow, deny = excluded.deny`,
int64(override.ChannelID), override.TargetType, int64(override.TargetID),
int64(override.Allow), int64(override.Deny))
return err
}
func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]ChannelOverride, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT channel_id, target_type, target_id, allow, deny
FROM channel_overrides WHERE channel_id = ?`, int64(channelID))
if err != nil {
return nil, err
}
defer rows.Close()
overrides := make([]ChannelOverride, 0, 4)
for rows.Next() {
var (
override ChannelOverride
allow, deny int64
)
if err := rows.Scan(&override.ChannelID, &override.TargetType, &override.TargetID, &allow, &deny); err != nil {
return nil, err
}
override.Allow = intToID(allow)
override.Deny = intToID(deny)
overrides = append(overrides, override)
}
return overrides, rows.Err()
}
func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
var (
channel Channel
guildID sql.NullInt64
parentID sql.NullInt64
nsfw int
background sql.NullString
createdAt string
)
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
&channel.VoiceStatus, &channel.UserLimit, &createdAt)
if err != nil {
return nil, mapError(err)
}
channel.GuildID = optionalID(guildID)
channel.ParentID = optionalID(parentID)
channel.NSFW = nsfw == 1
if background.Valid {
channel.Background = background.String
}
channel.CreatedAt = parseTimestamp(createdAt)
return &channel, nil
}
func collectChannels(rows *sql.Rows) ([]Channel, error) {
defer rows.Close()
channels := make([]Channel, 0, 8)
for rows.Next() {
channel, err := scanChannel(rows)
if err != nil {
return nil, err
}
channels = append(channels, *channel)
}
return channels, rows.Err()
}
+359
View File
@@ -0,0 +1,359 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
)
type Guild struct {
ID uint64
Name string
OwnerID uint64
IconFileID *uint64
BannerFileID *uint64
SplashFileID *uint64
AccentColor int64
Description string
IsMain bool
IsDiscoverable bool
Limits map[string]int
CreatedAt time.Time
}
type GuildMember struct {
GuildID uint64
UserID uint64
Nickname string
JoinedAt time.Time
TimeoutUntil *time.Time
}
// CreateGuildParams — параметры создания сервера. Роли по умолчанию создаёт
// вызывающий код (bootstrap или создатель сервера), чтобы можно было
// применить шаблон оформления (AGENT.md 7.3).
type CreateGuildParams struct {
ID uint64
Name string
OwnerID uint64
IsMain bool
IsDiscoverable bool
Description string
AccentColor int64
Limits map[string]int
}
const guildColumns = `id, name, owner_id, icon_file_id, banner_file_id, splash_file_id,
accent_color, description, is_main, is_discoverable, limits_json, created_at`
func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Guild, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
limits := "{}"
if len(params.Limits) > 0 {
if encoded, err := json.Marshal(params.Limits); err == nil {
limits = string(encoded)
}
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor,
boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, s.Now())
if err != nil {
return nil, err
}
return s.GetGuild(ctx, params.ID)
}
func (s *Store) GetGuild(ctx context.Context, id uint64) (*Guild, error) {
return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE id = ?`, int64(id)))
}
func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) {
return scanGuild(s.reader.QueryRowContext(ctx,
`SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`))
}
type UpdateGuildParams struct {
Name *string
Description *string
AccentColor *int64
IconFileID *uint64
BannerFileID *uint64
SplashFileID *uint64
IsDiscoverable *bool
Limits map[string]int
}
func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildParams) (*Guild, error) {
var (
sets []string
args []any
)
if params.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *params.Name)
}
if params.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *params.Description)
}
if params.AccentColor != nil {
sets = append(sets, "accent_color = ?")
args = append(args, *params.AccentColor)
}
if params.IconFileID != nil {
sets = append(sets, "icon_file_id = ?")
args = append(args, int64(*params.IconFileID))
}
if params.BannerFileID != nil {
sets = append(sets, "banner_file_id = ?")
args = append(args, int64(*params.BannerFileID))
}
if params.SplashFileID != nil {
sets = append(sets, "splash_file_id = ?")
args = append(args, int64(*params.SplashFileID))
}
if params.IsDiscoverable != nil {
sets = append(sets, "is_discoverable = ?")
args = append(args, boolToInt(*params.IsDiscoverable))
}
if params.Limits != nil {
encoded, err := json.Marshal(params.Limits)
if err != nil {
return nil, err
}
sets = append(sets, "limits_json = ?")
args = append(args, string(encoded))
}
if len(sets) == 0 {
return s.GetGuild(ctx, id)
}
args = append(args, int64(id))
result, err := s.writer.ExecContext(ctx, buildQuery(updateGuildTemplate, joinSets(sets)), args...)
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetGuild(ctx, id)
}
func (s *Store) DeleteGuild(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, int64(id))
return err
}
// ListGuildsForUser возвращает серверы пользователя (участие).
func (s *Store) ListGuildsForUser(ctx context.Context, userID uint64) ([]Guild, error) {
rows, err := s.reader.QueryContext(ctx, buildQuery(selectGuildsForUserTemplate, prefixColumns("g", guildColumns)), int64(userID))
if err != nil {
return nil, err
}
return collectGuilds(rows)
}
// ListAllGuilds используется инстанс-админом (AGENT.md 7.19).
func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) {
rows, err := s.reader.QueryContext(ctx, `SELECT `+guildColumns+` FROM guilds ORDER BY id`)
if err != nil {
return nil, err
}
return collectGuilds(rows)
}
func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count)
return count, err
}
func (s *Store) CountGuildMembers(ctx context.Context, guildID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE guild_id = ?`, int64(guildID)).Scan(&count)
return count, err
}
func (s *Store) AddGuildMember(ctx context.Context, guildID, userID uint64, nickname string) (*GuildMember, error) {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO guild_members (guild_id, user_id, nickname, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT (guild_id, user_id) DO NOTHING`,
int64(guildID), int64(userID), nullableString(nickname), s.Now())
if err != nil {
return nil, err
}
return s.GetGuildMember(ctx, guildID, userID)
}
func (s *Store) GetGuildMember(ctx context.Context, guildID, userID uint64) (*GuildMember, error) {
var (
member GuildMember
nickname sql.NullString
joinedAt string
timeoutUntil sql.NullString
)
err := s.reader.QueryRowContext(ctx, `
SELECT guild_id, user_id, nickname, joined_at, timeout_until
FROM guild_members WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)).
Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil)
if err != nil {
return nil, mapError(err)
}
if nickname.Valid {
member.Nickname = nickname.String
}
member.JoinedAt = parseTimestamp(joinedAt)
if timeoutUntil.Valid {
value := parseTimestamp(timeoutUntil.String)
member.TimeoutUntil = &value
}
return &member, nil
}
func (s *Store) ListGuildMembers(ctx context.Context, guildID uint64) ([]GuildMember, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT guild_id, user_id, nickname, joined_at, timeout_until
FROM guild_members WHERE guild_id = ? ORDER BY joined_at`, int64(guildID))
if err != nil {
return nil, err
}
defer rows.Close()
members := make([]GuildMember, 0, 16)
for rows.Next() {
var (
member GuildMember
nickname sql.NullString
joinedAt string
timeoutUntil sql.NullString
)
if err := rows.Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil); err != nil {
return nil, err
}
if nickname.Valid {
member.Nickname = nickname.String
}
member.JoinedAt = parseTimestamp(joinedAt)
if timeoutUntil.Valid {
value := parseTimestamp(timeoutUntil.String)
member.TimeoutUntil = &value
}
members = append(members, member)
}
return members, rows.Err()
}
func (s *Store) UpdateGuildMember(ctx context.Context, guildID, userID uint64, nickname *string, timeoutUntil *time.Time) error {
sets := []string{}
args := []any{}
if nickname != nil {
sets = append(sets, "nickname = ?")
args = append(args, nullableString(*nickname))
}
if timeoutUntil != nil {
if timeoutUntil.IsZero() {
sets = append(sets, "timeout_until = NULL")
} else {
sets = append(sets, "timeout_until = ?")
args = append(args, s.Timestamp(*timeoutUntil))
}
}
if len(sets) == 0 {
return nil
}
args = append(args, int64(guildID), int64(userID))
_, err := s.writer.ExecContext(ctx, buildQuery(updateGuildMemberTemplate, joinSets(sets)), args...)
return err
}
// TimeoutFromNow возвращает момент окончания тайм-аута (AGENT.md 7.10).
func TimeoutFromNow(days int) time.Time {
if days < 1 {
days = 1
}
return time.Now().UTC().AddDate(0, 0, days)
}
func (s *Store) RemoveGuildMember(ctx context.Context, guildID, userID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?`,
int64(guildID), int64(userID))
return err
}
func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID uint64) error {
result, err := s.writer.ExecContext(ctx, `UPDATE guilds SET owner_id = ? WHERE id = ?`,
int64(newOwnerID), int64(guildID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) {
var (
guild Guild
iconID sql.NullInt64
bannerID sql.NullInt64
splashID sql.NullInt64
isMain int
discover int
limitsJSON string
createdAt string
)
err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID,
&guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt)
if err != nil {
return nil, mapError(err)
}
guild.IconFileID = optionalID(iconID)
guild.BannerFileID = optionalID(bannerID)
guild.SplashFileID = optionalID(splashID)
guild.IsMain = isMain == 1
guild.IsDiscoverable = discover == 1
if limitsJSON != "" && limitsJSON != "{}" {
_ = json.Unmarshal([]byte(limitsJSON), &guild.Limits)
}
guild.CreatedAt = parseTimestamp(createdAt)
return &guild, nil
}
func collectGuilds(rows *sql.Rows) ([]Guild, error) {
defer rows.Close()
guilds := make([]Guild, 0, 8)
for rows.Next() {
guild, err := scanGuild(rows)
if err != nil {
return nil, err
}
guilds = append(guilds, *guild)
}
return guilds, rows.Err()
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func joinSets(sets []string) string {
return strings.Join(sets, ", ")
}
// prefixColumns добавляет префикс таблицы к списку колонок.
func prefixColumns(prefix, columns string) string {
parts := strings.Split(columns, ",")
for i, part := range parts {
parts[i] = prefix + "." + strings.TrimSpace(part)
}
return strings.Join(parts, ", ")
}
+193
View File
@@ -0,0 +1,193 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"math"
"time"
)
// InstanceSettings — настраиваемые флаги и лимиты инстанса (AGENT.md 6.5).
type InstanceSettings struct {
RegistrationEnabled bool
AntiBotEnabled bool
AllowGuildCreation bool
MainGuildID uint64
MaxGuildsPerUser int
MaxMembersPerGuild int
MaxMessageLength int
AuditRetentionDays int
raw map[string]string
}
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
rows, err := s.reader.QueryContext(ctx, instanceSettingsQuery)
if err != nil {
return nil, err
}
defer rows.Close()
values := map[string]string{}
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, err
}
values[key] = value
}
if err := rows.Err(); err != nil {
return nil, err
}
return settingsFromMap(values), nil
}
func settingsFromMap(values map[string]string) *InstanceSettings {
settings := &InstanceSettings{
RegistrationEnabled: values["registration_enabled"] == "true",
AntiBotEnabled: values["anti_bot_enabled"] == "true",
AllowGuildCreation: values["allow_guild_creation"] != "false",
MaxGuildsPerUser: parseSettingInt(values["max_guilds_per_user"], 10),
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
raw: values,
}
if mainGuild := values["main_guild_id"]; mainGuild != "" {
if parsed, ok := parseUint(mainGuild); ok {
settings.MainGuildID = parsed
}
}
return settings
}
// Value возвращает исходное значение настройки (для API и CLI).
func (s *InstanceSettings) Value(key string) string { return s.raw[key] }
func (s *Store) SetInstanceSetting(ctx context.Context, key, value string) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, value, s.Now())
return err
}
// AuditEntry — запись аудита сервера (AGENT.md 6.1, 7.10).
type AuditEntry struct {
ID uint64
GuildID *uint64
ActorID *uint64
ActorInstanceAdmin bool
Action string
TargetType string
TargetID *uint64
Reason string
Changes json.RawMessage
CreatedAt time.Time
}
type AuditParams struct {
GuildID *uint64
ActorID *uint64
ActorInstanceAdmin bool
Action string
TargetType string
TargetID *uint64
Reason string
Changes any
}
// RecordAudit пишет действие в аудит; ошибка записи не должна ломать основную
// операцию, поэтому вызывающий код логирует её отдельно (AGENT.md 7.18).
func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error {
changes := "{}"
if params.Changes != nil {
if encoded, err := json.Marshal(params.Changes); err == nil {
changes = string(encoded)
}
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO audit_log (id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(s.NextID()), nullableID(params.GuildID), nullableID(params.ActorID),
boolToInt(params.ActorInstanceAdmin), params.Action, params.TargetType,
nullableID(params.TargetID), params.Reason, changes, s.Now())
return err
}
func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.reader.QueryContext(ctx, `
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
FROM audit_log WHERE guild_id = ? ORDER BY id DESC LIMIT ?`, int64(guildID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]AuditEntry, 0, limit)
for rows.Next() {
var (
entry AuditEntry
guildID sql.NullInt64
actorID sql.NullInt64
targetID sql.NullInt64
isAdmin int
changes string
createdAt string
)
if err := rows.Scan(&entry.ID, &guildID, &actorID, &isAdmin, &entry.Action,
&entry.TargetType, &targetID, &entry.Reason, &changes, &createdAt); err != nil {
return nil, err
}
entry.GuildID = optionalID(guildID)
entry.ActorID = optionalID(actorID)
entry.TargetID = optionalID(targetID)
entry.ActorInstanceAdmin = isAdmin == 1
entry.Changes = json.RawMessage(changes)
entry.CreatedAt = parseTimestamp(createdAt)
entries = append(entries, entry)
}
return entries, rows.Err()
}
func nullableID(id *uint64) any {
if id == nil {
return nil
}
return int64(*id)
}
func optionalID(value sql.NullInt64) *uint64 {
if !value.Valid {
return nil
}
converted := intToID(value.Int64)
return &converted
}
func parseSettingInt(value string, fallback int) int {
parsed, ok := parseUint(value)
if !ok || parsed > math.MaxInt32 {
return fallback
}
return int(parsed)
}
func parseUint(value string) (uint64, bool) {
var result uint64
if value == "" {
return 0, false
}
for _, r := range value {
if r < '0' || r > '9' {
return 0, false
}
result = result*10 + uint64(r-'0')
}
return result, true
}
+294
View File
@@ -0,0 +1,294 @@
package store
import (
"context"
"database/sql"
"strings"
"time"
)
type Role struct {
ID uint64
GuildID uint64
Name string
Color int64
Position int
Permissions uint64
IsDefault bool
Mentionable bool
Hoist bool
CosmeticFrameID *uint64
CosmeticBadgeID *uint64
NickColor *int64
NickEffect string
CreatedAt time.Time
}
// CreateRoleParams — параметры роли. Пермишены хранятся как в SQLite (int64),
// а в модели — uint64: конвертация в одном месте.
type CreateRoleParams struct {
ID uint64
GuildID uint64
Name string
Color int64
Position int
Permissions uint64
IsDefault bool
Mentionable bool
Hoist bool
NickEffect string
}
const roleColumns = `id, guild_id, name, color, position, permissions, is_default,
mentionable, hoist, cosmetic_frame_id, cosmetic_badge_id, nick_color, nick_effect, created_at`
func (s *Store) CreateRole(ctx context.Context, params CreateRoleParams) (*Role, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
if params.NickEffect == "" {
params.NickEffect = "none"
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO roles (id, guild_id, name, color, position, permissions, is_default, mentionable, hoist, nick_effect, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), int64(params.GuildID), params.Name, params.Color, params.Position,
int64(params.Permissions), boolToInt(params.IsDefault), boolToInt(params.Mentionable),
boolToInt(params.Hoist), params.NickEffect, s.Now())
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
return nil, err
}
return s.GetRole(ctx, params.ID)
}
func (s *Store) GetRole(ctx context.Context, id uint64) (*Role, error) {
return scanRole(s.reader.QueryRowContext(ctx, `SELECT `+roleColumns+` FROM roles WHERE id = ?`, int64(id)))
}
// ListGuildRoles возвращает роли сервера по убыванию позиции (AGENT.md 7.4).
func (s *Store) ListGuildRoles(ctx context.Context, guildID uint64) ([]Role, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT `+roleColumns+` FROM roles WHERE guild_id = ? ORDER BY position DESC, id`, int64(guildID))
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]Role, 0, 4)
for rows.Next() {
role, err := scanRole(rows)
if err != nil {
return nil, err
}
roles = append(roles, *role)
}
return roles, rows.Err()
}
// DefaultRole возвращает роль @user (is_default) — базу для вычисления прав.
func (s *Store) DefaultRole(ctx context.Context, guildID uint64) (*Role, error) {
return scanRole(s.reader.QueryRowContext(ctx,
`SELECT `+roleColumns+` FROM roles WHERE guild_id = ? AND is_default = 1 ORDER BY position LIMIT 1`,
int64(guildID)))
}
type UpdateRoleParams struct {
Name *string
Color *int64
Position *int
Permissions *uint64
Mentionable *bool
Hoist *bool
CosmeticFrameID *uint64
CosmeticBadgeID *uint64
NickColor *int64
NickEffect *string
ClearFrame bool
ClearBadge bool
ClearNickColor bool
}
func (s *Store) UpdateRole(ctx context.Context, id uint64, params UpdateRoleParams) (*Role, error) {
sets := []string{}
args := []any{}
if params.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *params.Name)
}
if params.Color != nil {
sets = append(sets, "color = ?")
args = append(args, *params.Color)
}
if params.Position != nil {
sets = append(sets, "position = ?")
args = append(args, *params.Position)
}
if params.Permissions != nil {
sets = append(sets, "permissions = ?")
args = append(args, int64(*params.Permissions))
}
if params.Mentionable != nil {
sets = append(sets, "mentionable = ?")
args = append(args, boolToInt(*params.Mentionable))
}
if params.Hoist != nil {
sets = append(sets, "hoist = ?")
args = append(args, boolToInt(*params.Hoist))
}
if params.CosmeticFrameID != nil {
sets = append(sets, "cosmetic_frame_id = ?")
args = append(args, int64(*params.CosmeticFrameID))
}
if params.ClearFrame {
sets = append(sets, "cosmetic_frame_id = NULL")
}
if params.CosmeticBadgeID != nil {
sets = append(sets, "cosmetic_badge_id = ?")
args = append(args, int64(*params.CosmeticBadgeID))
}
if params.ClearBadge {
sets = append(sets, "cosmetic_badge_id = NULL")
}
if params.NickColor != nil {
sets = append(sets, "nick_color = ?")
args = append(args, *params.NickColor)
}
if params.ClearNickColor {
sets = append(sets, "nick_color = NULL")
}
if params.NickEffect != nil {
sets = append(sets, "nick_effect = ?")
args = append(args, *params.NickEffect)
}
if len(sets) == 0 {
return s.GetRole(ctx, id)
}
args = append(args, int64(id))
if _, err := s.writer.ExecContext(ctx, buildQuery(updateRoleTemplate, strings.Join(sets, ", ")), args...); err != nil {
return nil, err
}
return s.GetRole(ctx, id)
}
func (s *Store) DeleteRole(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, int64(id))
return err
}
// AssignRole выдаёт роль участнику (идемпотентно).
func (s *Store) AssignRole(ctx context.Context, guildID, userID, roleID uint64) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO member_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)
ON CONFLICT DO NOTHING`, int64(guildID), int64(userID), int64(roleID))
return err
}
func (s *Store) RemoveRole(ctx context.Context, guildID, userID, roleID uint64) error {
_, err := s.writer.ExecContext(ctx,
`DELETE FROM member_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?`,
int64(guildID), int64(userID), int64(roleID))
return err
}
// MemberRoles возвращает роли участника сервера.
func (s *Store) MemberRoles(ctx context.Context, guildID, userID uint64) ([]Role, error) {
rows, err := s.reader.QueryContext(ctx, buildQuery(selectMemberRolesTemplate, prefixColumns("r", roleColumns)),
int64(guildID), int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]Role, 0, 4)
for rows.Next() {
role, err := scanRole(rows)
if err != nil {
return nil, err
}
roles = append(roles, *role)
}
return roles, rows.Err()
}
// MemberRoleIDs возвращает идентификаторы ролей участника (для READY).
func (s *Store) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT role_id FROM member_roles WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]uint64, 0, 4)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, uint64(id))
}
return ids, rows.Err()
}
// RoleMemberCount считает участников с ролью (нужно для защиты от потери
// управления сервером, AGENT.md 6.3).
func (s *Store) RoleMemberCount(ctx context.Context, roleID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM member_roles WHERE role_id = ?`, int64(roleID)).Scan(&count)
return count, err
}
// GuildRolesWithManageRoles возвращает роли, дающие право управлять ролями.
func (s *Store) GuildRolesWithManageRoles(ctx context.Context, guildID uint64, permission uint64) ([]Role, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT `+roleColumns+` FROM roles
WHERE guild_id = ? AND (permissions & ?) <> 0`, int64(guildID), int64(permission))
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]Role, 0, 2)
for rows.Next() {
role, err := scanRole(rows)
if err != nil {
return nil, err
}
roles = append(roles, *role)
}
return roles, rows.Err()
}
func scanRole(scanner interface{ Scan(...any) error }) (*Role, error) {
var (
role Role
permissions int64
isDefault int
mentionable int
hoist int
frameID sql.NullInt64
badgeID sql.NullInt64
nickColor sql.NullInt64
createdAt string
)
err := scanner.Scan(&role.ID, &role.GuildID, &role.Name, &role.Color, &role.Position, &permissions,
&isDefault, &mentionable, &hoist, &frameID, &badgeID, &nickColor, &role.NickEffect, &createdAt)
if err != nil {
return nil, mapError(err)
}
role.Permissions = uint64(permissions)
role.IsDefault = isDefault == 1
role.Mentionable = mentionable == 1
role.Hoist = hoist == 1
role.CosmeticFrameID = optionalID(frameID)
role.CosmeticBadgeID = optionalID(badgeID)
if nickColor.Valid {
value := nickColor.Int64
role.NickColor = &value
}
role.CreatedAt = parseTimestamp(createdAt)
return &role, nil
}
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"database/sql"
"time"
)
// SessionTTL — время жизни сессии по умолчанию (AGENT.md 7.1).
const SessionTTL = 30 * 24 * time.Hour
// StepUpWindow — окно свежей аутентификации для чувствительных действий.
const StepUpWindow = 10 * time.Minute
type Session struct {
ID uint64
UserID uint64
TokenHash string
UserAgent string
IP string
CreatedAt time.Time
ExpiresAt time.Time
LastSeen time.Time
SteppedUpAt *time.Time
}
// SteppedUp сообщает, что сессия прошла step-up недавно (AGENT.md 7.1).
func (s *Session) SteppedUp(now time.Time) bool {
if s.SteppedUpAt == nil {
return false
}
return now.Sub(*s.SteppedUpAt) < StepUpWindow
}
type CreateSessionParams struct {
TokenHash string
UserAgent string
IP string
TTL time.Duration
}
func (s *Store) CreateSession(ctx context.Context, userID uint64, params CreateSessionParams) (*Session, error) {
if params.TTL <= 0 {
params.TTL = SessionTTL
}
now := s.now()
expires := now.Add(params.TTL)
_, err := s.writer.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int64(s.NextID()), int64(userID), params.TokenHash, params.UserAgent, params.IP,
s.Timestamp(now), s.Timestamp(expires), s.Timestamp(now),
)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
return nil, err
}
return s.GetSessionByTokenHash(ctx, params.TokenHash)
}
func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
FROM sessions WHERE token_hash = ?`, tokenHash)
return scanSession(row)
}
func (s *Store) ListSessions(ctx context.Context, userID uint64) ([]Session, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_seen DESC`,
int64(userID), s.Now())
if err != nil {
return nil, err
}
defer rows.Close()
sessions := make([]Session, 0, 4)
for rows.Next() {
session, err := scanSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *session)
}
return sessions, rows.Err()
}
// RotateSession заменяет хэш токена, сохраняя идентификатор сессии
// (защита от session fixation, AGENT.md 7.1).
func (s *Store) RotateSession(ctx context.Context, sessionID uint64, tokenHash string) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE sessions SET token_hash = ?, last_seen = ? WHERE id = ?`,
tokenHash, s.Now(), int64(sessionID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// MarkSteppedUp фиксирует успешный step-up для сессии.
func (s *Store) MarkSteppedUp(ctx context.Context, sessionID uint64) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE sessions SET stepped_up_at = ?, last_seen = ? WHERE id = ?`,
s.Now(), s.Now(), int64(sessionID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) TouchSession(ctx context.Context, sessionID uint64) error {
_, err := s.writer.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`, s.Now(), int64(sessionID))
return err
}
func (s *Store) DeleteSession(ctx context.Context, sessionID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, int64(sessionID))
return err
}
// DeleteSessionsForUser отзывает все сессии пользователя: используется при
// смене пароля и «выйти везде» (AGENT.md 7.1).
func (s *Store) DeleteSessionsForUser(ctx context.Context, userID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID))
return err
}
// DeleteOtherSessions отзывает все сессии, кроме текущей.
func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id <> ?`,
int64(userID), int64(keepSessionID))
return err
}
// DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4).
func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) {
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func scanSession(scanner interface{ Scan(...any) error }) (*Session, error) {
var (
session Session
createdAt string
expiresAt string
lastSeen string
steppedUpAt sql.NullString
)
err := scanner.Scan(
&session.ID, &session.UserID, &session.TokenHash, &session.UserAgent, &session.IP,
&createdAt, &expiresAt, &lastSeen, &steppedUpAt,
)
if err != nil {
return nil, mapError(err)
}
session.CreatedAt = parseTimestamp(createdAt)
session.ExpiresAt = parseTimestamp(expiresAt)
session.LastSeen = parseTimestamp(lastSeen)
if steppedUpAt.Valid {
value := parseTimestamp(steppedUpAt.String)
session.SteppedUpAt = &value
}
return &session, nil
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"sync"
"time"
)
// Snowflake — генератор сортируемых идентификаторов (AGENT.md 6.4):
// 41 бит времени от эпохи 2026-01-01T00:00:00Z, 10 бит узла, 12 бит счётчика.
type Snowflake struct {
mu sync.Mutex
epoch int64
node int64
lastMs int64
sequence int64
now func() time.Time
}
const (
snowflakeEpochMs = int64(1767225600000) // 2026-01-01T00:00:00Z
nodeBits = 10
sequenceBits = 12
maxNode = (1 << nodeBits) - 1
maxSequence = (1 << sequenceBits) - 1
)
func NewSnowflake(node uint16) *Snowflake {
return &Snowflake{
epoch: snowflakeEpochMs,
node: int64(node) & maxNode,
now: func() time.Time { return time.Now().UTC() },
}
}
// Next возвращает монотонно возрастающий идентификатор.
func (s *Snowflake) Next() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
nowMs := s.now().UnixMilli()
if nowMs < s.lastMs {
// Часы ушли назад: продолжаем с последней метки, чтобы не выдать дубль.
nowMs = s.lastMs
}
if nowMs == s.lastMs {
s.sequence = (s.sequence + 1) & maxSequence
if s.sequence == 0 {
// Счётчик переполнен в эту миллисекунду — ждём следующую.
for nowMs <= s.lastMs {
time.Sleep(time.Millisecond)
nowMs = s.now().UnixMilli()
}
}
} else {
s.sequence = 0
}
s.lastMs = nowMs
id := ((nowMs - s.epoch) << (nodeBits + sequenceBits)) | (s.node << sequenceBits) | s.sequence
return uint64(id)
}
// SnowflakeTime возвращает время создания идентификатора.
func SnowflakeTime(id uint64) time.Time {
ms := int64(id>>(nodeBits+sequenceBits)) + snowflakeEpochMs
return time.UnixMilli(ms).UTC()
}
+29
View File
@@ -0,0 +1,29 @@
package store
import "fmt"
// SQL-шаблоны, которые собираются из заранее известных фрагментов
// (имена колонок заданы в коде, значения всегда идут параметрами).
// Конкатенация через fmt.Sprintf здесь безопасна и проверяется тестами.
const updateGuildTemplate = `UPDATE guilds SET %s WHERE id = ?`
const selectGuildsForUserTemplate = `SELECT %s
FROM guilds g JOIN guild_members m ON m.guild_id = g.id
WHERE m.user_id = ? ORDER BY g.id`
const updateChannelTemplate = `UPDATE channels SET %s WHERE id = ?`
const updateGuildMemberTemplate = `UPDATE guild_members SET %s WHERE guild_id = ? AND user_id = ?`
const selectMemberRolesTemplate = `SELECT %s
FROM member_roles mr JOIN roles r ON r.id = mr.role_id
WHERE mr.guild_id = ? AND mr.user_id = ?
ORDER BY r.position DESC, r.id`
// buildQuery подставляет фрагмент SQL в шаблон.
func buildQuery(template string, fragment string) string {
return fmt.Sprintf(template, fragment)
}
const updateRoleTemplate = `UPDATE roles SET %s WHERE id = ?`
+112
View File
@@ -0,0 +1,112 @@
// Package store содержит доступ к данным glchat: модели и запросы поверх SQLite.
// Вся работа с БД идёт только через этот пакет (AGENT.md §12).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"time"
"glchat/internal/database"
)
var (
ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record already exists")
ErrPermissionDenied = errors.New("permission denied")
ErrLastAdminRole = errors.New("guild must keep a role with MANAGE_ROLES")
ErrLastAdministrator = errors.New("guild must keep administrator permissions")
)
// Store — точка доступа к данным. Writer используется для мутаций,
// Reader — для чтения (один писатель и пул чтения, AGENT.md §9.1).
type Store struct {
writer *sql.DB
reader *sql.DB
ids *Snowflake
now func() time.Time
}
func New(db *database.DB) *Store {
return &Store{
writer: db.Writer,
reader: db.Reader,
ids: NewSnowflake(0),
now: func() time.Time { return time.Now().UTC() },
}
}
// Reader возвращает соединение для чтения (доступно другим пакетам,
// которым нужны собственные запросы, например permissions).
func (s *Store) Reader() *sql.DB { return s.reader }
// Writer возвращает соединение для записи.
func (s *Store) Writer() *sql.DB { return s.writer }
// NextID выдаёт новый идентификатор (Snowflake, AGENT.md 6.4).
func (s *Store) NextID() uint64 { return s.ids.Next() }
// Timestamp форматирует время в ISO 8601 UTC с миллисекундами.
func (s *Store) Timestamp(t time.Time) string {
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
func (s *Store) Now() string { return s.Timestamp(s.now()) }
// InTx выполняет функцию в транзакции с откатом при ошибке.
func (s *Store) InTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
tx, err := s.writer.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
if err := fn(tx); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
return fmt.Errorf("%w (rollback: %w)", err, rollbackErr)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func mapError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
// idToInt переводит uint64-идентификатор в int64 для SQLite. Идентификаторы
// Snowflake формируются приложением, поэтому заведомо меньше MaxInt64;
// проверка диапазона сохраняет корректность при ручных данных.
func idToInt(id uint64) (int64, error) {
if id > math.MaxInt64 {
return 0, fmt.Errorf("identifier %d exceeds the supported range", id)
}
return int64(id), nil
}
// intToID переводит значение SQLite в uint64-идентификатор.
func intToID(value int64) uint64 {
if value < 0 {
return 0
}
return uint64(value)
}
// boolToInt превращает Go-флаг в значение SQLite (0/1).
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
// mustID конвертирует идентификатор, который уже проверен вызывающим кодом
// (значения Snowflake всегда в диапазоне int64).
func mustID(id uint64) int64 { return int64(id) }
+155
View File
@@ -0,0 +1,155 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"time"
)
// TOTPSecret — секрет второго фактора: в БД лежит зашифрованным (AGENT.md 9.2),
// резервные коды хранятся только хэшами.
type TOTPSecret struct {
UserID uint64
SecretEncrypted string
Enabled bool
RecoveryCodeHashs []string
ConfirmedAt *time.Time
CreatedAt time.Time
}
// UpsertTOTPSecret создаёт или заменяет неподтверждённый секрет.
func (s *Store) UpsertTOTPSecret(ctx context.Context, userID uint64, secretEncrypted string) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO totp_secrets (user_id, secret_encrypted, enabled, recovery_codes_json, created_at)
VALUES (?, ?, 0, '[]', ?)
ON CONFLICT (user_id) DO UPDATE SET
secret_encrypted = excluded.secret_encrypted,
enabled = 0,
recovery_codes_json = '[]',
confirmed_at = NULL,
created_at = excluded.created_at`,
int64(userID), secretEncrypted, s.Now())
return err
}
func (s *Store) GetTOTPSecret(ctx context.Context, userID uint64) (*TOTPSecret, error) {
var (
secret TOTPSecret
enabled int
codes string
confirmedAt sql.NullString
createdAt string
)
err := s.reader.QueryRowContext(ctx, `
SELECT user_id, secret_encrypted, enabled, recovery_codes_json, confirmed_at, created_at
FROM totp_secrets WHERE user_id = ?`, int64(userID)).
Scan(&secret.UserID, &secret.SecretEncrypted, &enabled, &codes, &confirmedAt, &createdAt)
if err != nil {
return nil, mapError(err)
}
secret.Enabled = enabled == 1
if err := json.Unmarshal([]byte(codes), &secret.RecoveryCodeHashs); err != nil {
secret.RecoveryCodeHashs = nil
}
if confirmedAt.Valid {
value := parseTimestamp(confirmedAt.String)
secret.ConfirmedAt = &value
}
secret.CreatedAt = parseTimestamp(createdAt)
return &secret, nil
}
// EnableTOTP подтверждает секрет и сохраняет хэши резервных кодов.
func (s *Store) EnableTOTP(ctx context.Context, userID uint64, recoveryCodeHashes []string) error {
encoded, err := json.Marshal(recoveryCodeHashes)
if err != nil {
return err
}
result, err := s.writer.ExecContext(ctx, `
UPDATE totp_secrets SET enabled = 1, recovery_codes_json = ?, confirmed_at = ?
WHERE user_id = ?`, string(encoded), s.Now(), int64(userID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ConsumeRecoveryCode удаляет использованный резервный код.
func (s *Store) ConsumeRecoveryCode(ctx context.Context, userID uint64, remaining []string) error {
encoded, err := json.Marshal(remaining)
if err != nil {
return err
}
_, err = s.writer.ExecContext(ctx, `UPDATE totp_secrets SET recovery_codes_json = ? WHERE user_id = ?`,
string(encoded), int64(userID))
return err
}
func (s *Store) DeleteTOTPSecret(ctx context.Context, userID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = ?`, int64(userID))
return err
}
// RecordSecurityEvent пишет событие безопасности (AGENT.md 6.1).
func (s *Store) RecordSecurityEvent(ctx context.Context, userID *uint64, eventType, ip, userAgent, metadata string) error {
var userValue any
if userID != nil {
userValue = int64(*userID)
}
if metadata == "" {
metadata = "{}"
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO security_events (id, user_id, type, ip, user_agent, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
int64(s.NextID()), userValue, eventType, ip, userAgent, metadata, s.Now())
return err
}
// ListSecurityEvents возвращает последние события пользователя.
func (s *Store) ListSecurityEvents(ctx context.Context, userID uint64, limit int) ([]SecurityEvent, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.reader.QueryContext(ctx, `
SELECT id, user_id, type, ip, user_agent, metadata_json, created_at
FROM security_events WHERE user_id = ? ORDER BY id DESC LIMIT ?`, int64(userID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]SecurityEvent, 0, limit)
for rows.Next() {
var (
event SecurityEvent
userValue sql.NullInt64
createdAt string
metadataRaw string
)
if err := rows.Scan(&event.ID, &userValue, &event.Type, &event.IP, &event.UserAgent, &metadataRaw, &createdAt); err != nil {
return nil, err
}
if userValue.Valid {
event.UserID = uint64(userValue.Int64)
}
event.Metadata = json.RawMessage(metadataRaw)
event.CreatedAt = parseTimestamp(createdAt)
events = append(events, event)
}
return events, rows.Err()
}
type SecurityEvent struct {
ID uint64
UserID uint64
Type string
IP string
UserAgent string
Metadata json.RawMessage
CreatedAt time.Time
}
+273
View File
@@ -0,0 +1,273 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
)
// User — аккаунт пользователя. Email в БД лежит зашифрованным (AGENT.md 9.2),
// поэтому в модели он заполняется только тогда, когда реально расшифрован.
type User struct {
ID uint64
Username string
DisplayName string
Email string
PasswordHash string
AvatarFileID *uint64
BannerFileID *uint64
Bio string
Status string
CustomStatus string
CustomStatusEmoji string
Flags int64
IsInstanceAdmin bool
Badges []string
Locale string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// CreateUserParams — данные новой учётной записи: шифрование и blind index
// выполняет вызывающий код (internal/auth), чтобы store не знал про ключи.
type CreateUserParams struct {
ID uint64
Username string
DisplayName string
EmailEnc string
EmailIndex string
PasswordHash string
Locale string
}
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at`
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
if params.DisplayName == "" {
params.DisplayName = params.Username
}
if params.Locale == "" {
params.Locale = "ru"
}
ts := s.Now()
_, err := s.writer.ExecContext(ctx, `
INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), params.Username, strings.ToLower(params.Username), params.DisplayName,
params.EmailEnc, params.EmailIndex, params.PasswordHash, params.Locale, ts, ts,
)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
return nil, err
}
return s.GetUser(ctx, params.ID)
}
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id))
return scanUser(row)
}
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.reader.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`,
strings.ToLower(username))
return scanUser(row)
}
// GetUserByEmailIndex ищет пользователя по blind index: сам email в запросе
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
row := s.reader.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex)
return scanUser(row)
}
// EncryptedEmail возвращает сохранённый шифротекст email для расшифровки.
func (s *Store) EncryptedEmail(ctx context.Context, id uint64) (string, error) {
var encrypted string
err := s.reader.QueryRowContext(ctx, `SELECT email_enc FROM users WHERE id = ?`, int64(id)).Scan(&encrypted)
if err != nil {
return "", mapError(err)
}
return encrypted, nil
}
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var count int
if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
type UpdateUserParams struct {
DisplayName *string
Bio *string
Status *string
CustomStatus *string
CustomStatusEmoji *string
AvatarFileID *uint64
BannerFileID *uint64
Locale *string
}
func (s *Store) UpdateUser(ctx context.Context, id uint64, params UpdateUserParams) (*User, error) {
var (
sets []string
args []any
)
add := func(column string, value any) {
sets = append(sets, column+" = ?")
args = append(args, value)
}
if params.DisplayName != nil {
add("display_name", *params.DisplayName)
}
if params.Bio != nil {
add("bio", *params.Bio)
}
if params.Status != nil {
add("status", *params.Status)
}
if params.CustomStatus != nil {
add("custom_status", *params.CustomStatus)
}
if params.CustomStatusEmoji != nil {
add("custom_status_emoji", *params.CustomStatusEmoji)
}
if params.AvatarFileID != nil {
add("avatar_file_id", int64(*params.AvatarFileID))
}
if params.BannerFileID != nil {
add("banner_file_id", int64(*params.BannerFileID))
}
if params.Locale != nil {
add("locale", *params.Locale)
}
if len(sets) == 0 {
return s.GetUser(ctx, id)
}
add("updated_at", s.Now())
args = append(args, int64(id))
result, err := s.writer.ExecContext(ctx, `UPDATE users SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...)
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetUser(ctx, id)
}
// UpdateUserPassword меняет хэш пароля и возвращает прежнее значение, чтобы
// вызывающий код мог отозвать все сессии (AGENT.md 7.1).
func (s *Store) UpdateUserPassword(ctx context.Context, id uint64, passwordHash string) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
passwordHash, s.Now(), int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetInstanceAdmin(ctx context.Context, id uint64, admin bool) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE users SET is_instance_admin = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
boolToInt(admin), s.Now(), int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) error {
encoded, err := json.Marshal(badges)
if err != nil {
return err
}
result, err := s.writer.ExecContext(ctx,
`UPDATE users SET badges_json = ?, updated_at = ? WHERE id = ?`,
string(encoded), s.Now(), int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
var (
user User
avatarID sql.NullInt64
bannerID sql.NullInt64
isAdmin int
badges string
createdAt string
updatedAt string
deletedAt sql.NullString
emailEncrypted string
)
err := scanner.Scan(
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
)
if err != nil {
return nil, mapError(err)
}
if avatarID.Valid {
value := uint64(avatarID.Int64)
user.AvatarFileID = &value
}
if bannerID.Valid {
value := uint64(bannerID.Int64)
user.BannerFileID = &value
}
user.IsInstanceAdmin = isAdmin == 1
if err := json.Unmarshal([]byte(badges), &user.Badges); err != nil {
user.Badges = nil
}
user.CreatedAt = parseTimestamp(createdAt)
user.UpdatedAt = parseTimestamp(updatedAt)
if deletedAt.Valid {
value := parseTimestamp(deletedAt.String)
user.DeletedAt = &value
}
return &user, nil
}
func parseTimestamp(value string) time.Time {
for _, layout := range []string{"2006-01-02T15:04:05.000Z", time.RFC3339Nano, time.RFC3339} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed.UTC()
}
}
return time.Time{}
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
}