From 605b2038f1dc9d58e85fbaa41f0d68fc9653cd8c Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:45:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(templates):=20=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=B8?= =?UTF-8?q?=20API=20(=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера (роли с правами и цветом, категории и комнаты) и создать по ней новый. Миграция 00023 создаёт guild_templates (structure_json, source_guild_id, каскад по владельцу) с индексом владельца. - store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов (20 шаблонов, 16 КиБ structure_json); - server: POST /guild-templates, GET /guild-templates/mine и DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404), создание сервера по template_id=personal: (аудит template=personal:); - структура шаблона повторяет API ролей (права строкой) и хранит slowmode_seconds, user_limit и категорию по имени. --- .../guild_templates_migration_test.go | 158 ++++++ .../00023_phase7_guild_templates.sql | 23 + internal/server/api_guilds.go | 15 +- internal/server/guild_templates.go | 511 +++++++++++++++++- internal/server/guild_templates_test.go | 464 +++++++++++++++- internal/store/guild_templates.go | 161 ++++++ internal/store/guild_templates_test.go | 242 +++++++++ 7 files changed, 1552 insertions(+), 22 deletions(-) create mode 100644 internal/database/guild_templates_migration_test.go create mode 100644 internal/database/migrations/00023_phase7_guild_templates.sql create mode 100644 internal/store/guild_templates.go create mode 100644 internal/store/guild_templates_test.go diff --git a/internal/database/guild_templates_migration_test.go b/internal/database/guild_templates_migration_test.go new file mode 100644 index 0000000..8009eed --- /dev/null +++ b/internal/database/guild_templates_migration_test.go @@ -0,0 +1,158 @@ +package database + +import ( + "context" + "path/filepath" + "testing" + + "github.com/pressly/goose/v3" +) + +// TestGuildTemplatesMigration проверяет миграцию 00023 на чистой базе: +// таблица личных шаблонов, индекс владельца, внешние ключи (каскад по +// пользователю, обнуление сервера-источника) и откат миграции (AGENT.md 7.3). +func TestGuildTemplatesMigration(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("Open() вернул ошибку: %v", err) + } + defer func() { + if err := db.Close(); err != nil { + t.Errorf("Close(): %v", err) + } + }() + + version, err := db.SchemaVersion(ctx) + if err != nil { + t.Fatalf("SchemaVersion(): %v", err) + } + if version < 23 { + t.Fatalf("schema_version = %d, ожидалось >= 23", version) + } + + columns := map[string]bool{} + rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('guild_templates')`) + if err != nil { + t.Fatalf("pragma_table_info: %v", err) + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan: %v", err) + } + columns[name] = true + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + for _, expected := range []string{ + "id", "owner_id", "name", "description", "structure_json", "source_guild_id", "created_at", + } { + if !columns[expected] { + t.Errorf("в guild_templates нет колонки %q", expected) + } + } + + var indexes int + if err := db.Reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'guild_templates_owner_idx'`, + ).Scan(&indexes); err != nil { + t.Fatalf("чтение индекса: %v", err) + } + if indexes != 1 { + t.Error("индекс guild_templates_owner_idx не создан") + } + + ownerID := int64(2000) + otherID := int64(2001) + insertUser := `INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index, + password_hash, locale, created_at, updated_at) + VALUES (?, ?, ?, ?, 'enc', ?, 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')` + if _, err := db.Writer.ExecContext(ctx, insertUser, ownerID, "tpl", "tpl", "Tpl", "idx-tpl"); err != nil { + t.Fatalf("вставка владельца: %v", err) + } + if _, err := db.Writer.ExecContext(ctx, insertUser, otherID, "tpl2", "tpl2", "Tpl2", "idx-tpl2"); err != nil { + t.Fatalf("вставка второго пользователя: %v", err) + } + guildID := int64(3000) + if _, err := db.Writer.ExecContext(ctx, + `INSERT INTO guilds (id, name, owner_id, created_at) VALUES (?, 'Источник', ?, '2026-01-01T00:00:00.000Z')`, + guildID, ownerID); err != nil { + t.Fatalf("вставка сервера: %v", err) + } + + insertTemplate := `INSERT INTO guild_templates (id, owner_id, name, structure_json, source_guild_id, created_at) + VALUES (?, ?, 'Шаблон', '{"roles":[],"channels":[]}', ?, '2026-01-01T00:00:00.000Z')` + if _, err := db.Writer.ExecContext(ctx, insertTemplate, 1, ownerID, guildID); err != nil { + t.Fatalf("вставка шаблона: %v", err) + } + // Владелец обязателен: внешний ключ на users. + if _, err := db.Writer.ExecContext(ctx, insertTemplate, 2, 999999, nil); err == nil { + t.Fatal("шаблон без пользователя принят, ожидалась ошибка внешнего ключа") + } + // Удаление сервера-источника не удаляет шаблон, а лишь обнуляет ссылку. + if _, err := db.Writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, guildID); err != nil { + t.Fatalf("удаление сервера: %v", err) + } + var source *int64 + if err := db.Reader.QueryRowContext(ctx, + `SELECT source_guild_id FROM guild_templates WHERE id = 1`).Scan(&source); err != nil { + t.Fatalf("чтение шаблона: %v", err) + } + if source != nil { + t.Errorf("source_guild_id = %d, ожидалось NULL", *source) + } + // Удаление пользователя убирает его шаблоны. + if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, ownerID); err != nil { + t.Fatalf("удаление владельца: %v", err) + } + var remaining int + if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_templates`).Scan(&remaining); err != nil { + t.Fatalf("подсчёт шаблонов: %v", err) + } + if remaining != 0 { + t.Errorf("после удаления владельца осталось шаблонов: %d", remaining) + } + + // Откат: goose должен вернуть версию 22 и убрать таблицу. + goose.SetBaseFS(migrationsFS) + goose.SetLogger(goose.NopLogger()) + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatalf("SetDialect: %v", err) + } + if err := goose.DownToContext(ctx, db.Writer, "migrations", 22); err != nil { + t.Fatalf("откат миграции: %v", err) + } + var tables int + if err := db.Reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`, + ).Scan(&tables); err != nil { + t.Fatalf("чтение sqlite_master: %v", err) + } + if tables != 0 { + t.Error("после отката таблица guild_templates осталась") + } + if version, err := db.SchemaVersion(ctx); err != nil { + t.Fatalf("SchemaVersion после отката: %v", err) + } else if version != 22 { + t.Errorf("после отката schema_version = %d, ожидалось 22", version) + } + // Повторное применение возвращает таблицу: миграция идемпотентна по goose. + if err := db.Migrate(ctx); err != nil { + t.Fatalf("повторная миграция: %v", err) + } + if err := db.Reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`, + ).Scan(&tables); err != nil { + t.Fatalf("чтение sqlite_master: %v", err) + } + if tables != 1 { + t.Error("после повторного применения таблица guild_templates не создана") + } +} diff --git a/internal/database/migrations/00023_phase7_guild_templates.sql b/internal/database/migrations/00023_phase7_guild_templates.sql new file mode 100644 index 0000000..7abac30 --- /dev/null +++ b/internal/database/migrations/00023_phase7_guild_templates.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Фаза 7: личные шаблоны серверов (AGENT.md 7.3). +-- +-- Шаблон — приватная сущность пользователя: он виден и используется только +-- владельцем (owner_id), поэтому у таблицы нет guild_id и связанных с ним +-- прав. structure_json хранит структуру в том же виде, что внутренний +-- guildTemplate: роли с цветом и правами, категории и комнаты. source_guild_id +-- — сервер, с которого структура снята; при удалении сервера ссылка +-- обнуляется, а сам шаблон остаётся рабочим. Вместе с пользователем его +-- шаблоны удаляются каскадом. +CREATE TABLE guild_templates ( + id INTEGER PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + structure_json TEXT NOT NULL, + source_guild_id INTEGER REFERENCES guilds (id) ON DELETE SET NULL, + created_at TEXT NOT NULL +); +CREATE INDEX guild_templates_owner_idx ON guild_templates (owner_id, id DESC); + +-- +goose Down +DROP TABLE guild_templates; diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 8007b20..64bd320 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -271,6 +271,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { return output, nil }) + // Личные шаблоны серверов (Фаза 7): сохранить структуру, список и удаление. + s.registerUserGuildTemplateRoutes(api, security) + huma.Register(api, huma.Operation{ OperationID: "getGuild", Method: http.MethodGet, @@ -1812,11 +1815,12 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin } } -// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3). +// createGuild создаёт сервер с ролями по умолчанию и структурой выбранного +// шаблона — встроенного или личного (AGENT.md 6.3, 7.3). func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) { - template, known := findGuildTemplate(templateID) - if !known { - return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + template, templateReason, err := s.resolveGuildTemplate(ctx, owner, templateID) + if err != nil { + return nil, err } settings, err := s.store.InstanceSettings(ctx) if err != nil { @@ -1860,7 +1864,8 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templ return nil, humaError(err) } } - s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID) + // Причина аудита: встроенный шаблон или personal: у личного (7.3). + s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, templateReason) if bypassed { s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit") } diff --git a/internal/server/guild_templates.go b/internal/server/guild_templates.go index 36d30ae..7152f66 100644 --- a/internal/server/guild_templates.go +++ b/internal/server/guild_templates.go @@ -2,33 +2,50 @@ package server import ( "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/danielgtaylor/huma/v2" "glchat/internal/permissions" "glchat/internal/store" ) // Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный -// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после -// MVP, поэтому каталог статический. +// шаблон — структура комнат и трёх ролей без сообщений — или свой, снятый с +// существующего сервера. Встроенный каталог статический (guildTemplates), +// личные шаблоны живут в БД (guild_templates) и приватны для владельца. // guildTemplateRole — роль, которую создаёт шаблон. type guildTemplateRole struct { Name string Permissions []permissions.Permission - Position int + // Color — цвет роли в RGB (0 — по умолчанию). + Color int64 + Position int // Hoist — показывать участников с этой ролью отдельной группой. Hoist bool + // Mentionable — роль разрешено упоминать. + Mentionable bool } // guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри // которой она создаётся (пусто — верхний уровень). type guildTemplateChannel struct { - Name string - Type store.ChannelType - Category string + Name string + Type store.ChannelType + Category string + SlowmodeSeconds int + UserLimit int } -// guildTemplate — встроенный шаблон сервера. +// guildTemplate — шаблон сервера: встроенный из каталога или личный, +// разобранный из structure_json (AGENT.md 7.3). type guildTemplate struct { ID string Name string @@ -60,8 +77,9 @@ var guildTemplates = []guildTemplate{ permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers, permissions.MoveMembers, }, - Position: 50, - Hoist: true, + Position: 50, + Hoist: true, + Mentionable: true, }, }, Channels: []guildTemplateChannel{ @@ -85,8 +103,9 @@ var guildTemplates = []guildTemplate{ permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard, permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks, }, - Position: 50, - Hoist: true, + Position: 50, + Hoist: true, + Mentionable: true, }, }, Channels: []guildTemplateChannel{ @@ -141,10 +160,11 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{ GuildID: guild.ID, Name: role.Name, + Color: role.Color, Permissions: uint64(permissionSet(role.Permissions)), Position: role.Position, Hoist: role.Hoist, - Mentionable: true, + Mentionable: role.Mentionable, }); err != nil { return humaError(err) } @@ -177,10 +197,12 @@ func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, tem continue } params := store.CreateChannelParams{ - GuildID: &guild.ID, - Type: channel.Type, - Name: channel.Name, - Position: position, + GuildID: &guild.ID, + Type: channel.Type, + Name: channel.Name, + Position: position, + SlowmodeSeconds: channel.SlowmodeSeconds, + UserLimit: channel.UserLimit, } if parentID, ok := categories[channel.Category]; ok { params.ParentID = &parentID @@ -202,6 +224,9 @@ type guildTemplatePayload struct { TextCount int `json:"text_channels"` VoiceCount int `json:"voice_channels"` Categories []string `json:"categories"` + // CreatedAt и SourceGuildID заполнены только у личных шаблонов. + CreatedAt string `json:"created_at,omitempty"` + SourceGuildID string `json:"source_guild_id,omitempty"` } type guildTemplateListOutput struct { @@ -240,3 +265,457 @@ func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload { } return payload } + +// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет структуру +// своего сервера и создаёт по ней новые. Шаблон приватный — видит и использует +// только владелец; чужой шаблон неотличим от несуществующего (AGENT.md 9.7). +// +// Записи в аудит на CRUD личных шаблонов нет осознанно: шаблон — приватная +// сущность пользователя, у неё нет сервера, а журнал аудита живёт на сервере +// (AGENT.md 7.10). Единственное действие, попадающее в аудит, — создание +// сервера по шаблону: там причина содержит template=. +const ( + // personalTemplatePrefix — личный шаблон в template_id: personal:. + personalTemplatePrefix = "personal:" + // maxUserGuildTemplateName и maxUserGuildTemplateDescription повторяют + // ограничения имён сервера и коротких описаний. + maxUserGuildTemplateName = 64 + maxUserGuildTemplateDescription = 200 + // maxUserGuildTemplateRoles и maxUserGuildTemplateChannels ограничивают + // саму структуру: категории считаются комнатами. + maxUserGuildTemplateRoles = 50 + maxUserGuildTemplateChannels = 100 +) + +// userGuildTemplateStructure — structure_json личного шаблона. Формат повторяет +// внутренний guildTemplate: роли (кроме роли по умолчанию) с цветом, позицией +// и строкой прав, затем категории и комнаты. Права — строкой как в API ролей +// («VIEW_CHANNEL|SEND_MESSAGES», «NONE» — нет прав): маска uint64 не теряет +// точность в JSON. Числовая строка при разборе тоже принимается. +type userGuildTemplateStructure struct { + Roles []userGuildTemplateRole `json:"roles"` + Channels []userGuildTemplateChannel `json:"channels"` +} + +type userGuildTemplateRole struct { + Name string `json:"name"` + Color int64 `json:"color"` + Permissions string `json:"permissions"` + Position int `json:"position"` + Hoist bool `json:"hoist"` + Mentionable bool `json:"mentionable"` +} + +type userGuildTemplateChannel struct { + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category,omitempty"` + Position int `json:"position"` + SlowmodeSeconds int `json:"slowmode_seconds,omitempty"` + UserLimit int `json:"user_limit,omitempty"` +} + +type userGuildTemplateOutput struct { + Body struct { + Template guildTemplatePayload `json:"template"` + } +} + +type userGuildTemplateListOutput struct { + Body struct { + Templates []guildTemplatePayload `json:"templates"` + } +} + +type guildTemplateDeleteOutput struct { + Body struct { + OK bool `json:"ok"` + } +} + +// registerUserGuildTemplateRoutes описывает ручки личных шаблонов серверов. +func (s *Server) registerUserGuildTemplateRoutes(api huma.API, security []map[string][]string) { + huma.Register(api, huma.Operation{ + OperationID: "createUserGuildTemplate", + Method: http.MethodPost, + Path: "/guild-templates", + Summary: "Сохранить структуру сервера личным шаблоном", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + Body struct { + GuildID string `json:"guild_id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + } + }, + ) (*userGuildTemplateOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, err := parseID("guild_id", input.Body.GuildID) + if err != nil { + return nil, err + } + // Право MANAGE_GUILD: администратор инстанса проходит всегда + // (permissions.Resolved.IsInstanceAdmin). + resolved, err := s.guildPermissions(ctx, guildID, user) + if err != nil { + return nil, err + } + if !resolved.Has(permissions.ManageGuild) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") + } + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + + name := strings.TrimSpace(input.Body.Name) + if name == "" { + name = guild.Name + } + if len([]rune(name)) > maxUserGuildTemplateName { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template name must be 1-64 characters") + } + description := strings.TrimSpace(input.Body.Description) + if len([]rune(description)) > maxUserGuildTemplateDescription { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "template description must be at most 200 characters") + } + + count, err := s.store.CountUserGuildTemplates(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + if count >= store.MaxUserGuildTemplates { + return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached") + } + + structure, err := s.buildUserGuildTemplateStructure(ctx, guildID) + if err != nil { + return nil, err + } + if len(structure.Roles) > maxUserGuildTemplateRoles || len(structure.Channels) > maxUserGuildTemplateChannels { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild structure is too large for a template: at most 50 roles and 100 channels") + } + encoded, err := json.Marshal(structure) + if err != nil { + return nil, humaError(err) + } + if len(encoded) > store.MaxUserGuildTemplateBytes { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large") + } + + created, err := s.store.CreateUserGuildTemplate(ctx, store.CreateUserGuildTemplateParams{ + OwnerID: user.ID, + Name: name, + Description: description, + Structure: encoded, + SourceGuildID: &guildID, + }) + if err != nil { + switch { + case errors.Is(err, store.ErrTemplateLimitReached): + return nil, humaErrorStatus(http.StatusConflict, "template.limit_reached", "personal guild template limit is reached") + case errors.Is(err, store.ErrTemplateTooLarge): + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is too large") + } + return nil, humaError(err) + } + output := &userGuildTemplateOutput{} + output.Body.Template = s.userGuildTemplatePayload(*created) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listUserGuildTemplates", + Method: http.MethodGet, + Path: "/guild-templates/mine", + Summary: "Свои шаблоны серверов", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*userGuildTemplateListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + templates, err := s.store.ListUserGuildTemplates(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + output := &userGuildTemplateListOutput{} + output.Body.Templates = make([]guildTemplatePayload, 0, len(templates)) + for _, template := range templates { + output.Body.Templates = append(output.Body.Templates, s.userGuildTemplatePayload(template)) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteUserGuildTemplate", + Method: http.MethodDelete, + Path: "/guild-templates/{template_id}", + Summary: "Удалить свой шаблон сервера", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + TemplateID string `path:"template_id"` + }, + ) (*guildTemplateDeleteOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + // Принимаем и «personal:» (как в списке шаблонов клиента), и сам id. + templateID, err := parseID("template_id", strings.TrimPrefix(input.TemplateID, personalTemplatePrefix)) + if err != nil { + return nil, err + } + deleted, err := s.store.DeleteUserGuildTemplate(ctx, templateID, user.ID) + if err != nil { + return nil, humaError(err) + } + if !deleted { + // Чужой шаблон неотличим от несуществующего (AGENT.md 9.7). + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "guild template not found") + } + output := &guildTemplateDeleteOutput{} + output.Body.OK = true + return output, nil + }) +} + +// buildUserGuildTemplateStructure снимает структуру сервера: роли (кроме роли +// по умолчанию — «Пользователь» создаётся для каждого сервера сам, AGENT.md 6.3) +// и все комнаты вместе с категориями. +func (s *Server) buildUserGuildTemplateStructure(ctx context.Context, guildID uint64) (userGuildTemplateStructure, error) { + roles, err := s.store.ListGuildRoles(ctx, guildID) + if err != nil { + return userGuildTemplateStructure{}, humaError(err) + } + structure := userGuildTemplateStructure{ + Roles: make([]userGuildTemplateRole, 0, len(roles)), + Channels: make([]userGuildTemplateChannel, 0, 8), + } + for _, role := range roles { + if role.IsDefault { + continue + } + structure.Roles = append(structure.Roles, userGuildTemplateRole{ + Name: role.Name, + Color: role.Color, + Permissions: permissions.Permission(role.Permissions).String(), + Position: role.Position, + Hoist: role.Hoist, + Mentionable: role.Mentionable, + }) + } + + channels, err := s.store.ListGuildChannels(ctx, guildID) + if err != nil { + return userGuildTemplateStructure{}, humaError(err) + } + // Комната ссылается на категорию по имени: идентификаторы в новом сервере + // будут другими (AGENT.md 6.4). + categories := make(map[uint64]string, len(channels)) + for _, channel := range channels { + if channel.Type == store.ChannelCategory { + categories[channel.ID] = channel.Name + } + } + for _, channel := range channels { + if channel.Type == store.ChannelDM { + continue + } + item := userGuildTemplateChannel{ + Name: channel.Name, + Type: string(channel.Type), + Position: channel.Position, + SlowmodeSeconds: channel.SlowmodeSeconds, + UserLimit: channel.UserLimit, + } + if channel.ParentID != nil { + item.Category = categories[*channel.ParentID] + } + structure.Channels = append(structure.Channels, item) + } + return structure, nil +} + +// structureToGuildTemplate превращает сохранённую структуру во внутренний +// шаблон: порядок комнат задаёт position. Битый шаблон — ошибка целиком: +// половина сервера хуже, чем отказ. +func structureToGuildTemplate(structure userGuildTemplateStructure) (guildTemplate, error) { + template := guildTemplate{ + Roles: make([]guildTemplateRole, 0, len(structure.Roles)), + Channels: make([]guildTemplateChannel, 0, len(structure.Channels)), + } + for _, role := range structure.Roles { + name := strings.TrimSpace(role.Name) + if name == "" { + return guildTemplate{}, errors.New("role name must not be empty") + } + mask, err := parseTemplatePermissions(role.Permissions) + if err != nil { + return guildTemplate{}, err + } + template.Roles = append(template.Roles, guildTemplateRole{ + Name: name, + Permissions: permissionList(mask), + Color: role.Color, + Position: role.Position, + Hoist: role.Hoist, + Mentionable: role.Mentionable, + }) + } + channels := make([]userGuildTemplateChannel, len(structure.Channels)) + copy(channels, structure.Channels) + sort.SliceStable(channels, func(i, j int) bool { return channels[i].Position < channels[j].Position }) + for _, channel := range channels { + name := strings.TrimSpace(channel.Name) + if name == "" { + return guildTemplate{}, errors.New("channel name must not be empty") + } + channelType := store.ChannelType(channel.Type) + switch channelType { + case store.ChannelText, store.ChannelVoice, store.ChannelCategory: + case store.ChannelDM: + return guildTemplate{}, errors.New("dm channels are not allowed in a template") + default: + return guildTemplate{}, fmt.Errorf("unknown channel type %q", channel.Type) + } + template.Channels = append(template.Channels, guildTemplateChannel{ + Name: name, + Type: channelType, + Category: channel.Category, + SlowmodeSeconds: channel.SlowmodeSeconds, + UserLimit: channel.UserLimit, + }) + } + return template, nil +} + +// permissionList разворачивает маску в список прав — внутренний вид шаблона. +func permissionList(mask permissions.Permission) []permissions.Permission { + list := make([]permissions.Permission, 0, 8) + for bit := permissions.Permission(1); bit != 0 && bit <= permissions.Administrator; bit <<= 1 { + if mask.Has(bit) { + list = append(list, bit) + } + } + return list +} + +// parseTemplatePermissions разбирает права роли из structure_json: строка +// названий через «|» (как в API ролей), «NONE» или пустая строка — нет прав; +// числовая строка-маска тоже принимается. +func parseTemplatePermissions(value string) (permissions.Permission, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" || strings.EqualFold(trimmed, "NONE") { + return 0, nil + } + if mask, err := strconv.ParseUint(trimmed, 10, 64); err == nil { + return permissions.Permission(mask), nil + } + mask, err := permissions.Parse(trimmed) + if err != nil { + return 0, err + } + return mask, nil +} + +// resolveGuildTemplate выбирает шаблон структуры для нового сервера: встроенный +// по идентификатору каталога или личный по префиксу personal:. Второе +// значение — причина для аудита guild.create (AGENT.md 7.3). +func (s *Server) resolveGuildTemplate(ctx context.Context, user *store.User, templateID string) (guildTemplate, string, error) { + if !strings.HasPrefix(templateID, personalTemplatePrefix) { + template, known := findGuildTemplate(templateID) + if !known { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + } + return template, "template=" + template.ID, nil + } + + id, err := parseID("template_id", strings.TrimPrefix(templateID, personalTemplatePrefix)) + if err != nil { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + } + stored, err := s.store.GetUserGuildTemplate(ctx, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + } + return guildTemplate{}, "", humaError(err) + } + // Чужой личный шаблон неотличим от несуществующего (AGENT.md 9.7). + if stored.OwnerID != user.ID { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template") + } + var structure userGuildTemplateStructure + if err := json.Unmarshal(stored.Structure, &structure); err != nil { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid") + } + template, err := structureToGuildTemplate(structure) + if err != nil { + return guildTemplate{}, "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild template structure is invalid") + } + return template, "template=" + personalTemplatePrefix + formatSnowflake(stored.ID), nil +} + +// userGuildTemplatePayload описывает личный шаблон для клиента: тот же +// предпросмотр, что у встроенных шаблонов, плюс дата создания и сервер-источник. +func (s *Server) userGuildTemplatePayload(template store.UserGuildTemplate) guildTemplatePayload { + payload := guildTemplatePayload{ + ID: formatSnowflake(template.ID), + Name: template.Name, + Description: template.Description, + Roles: []string{"Администратор", "Пользователь"}, + Categories: []string{}, + CreatedAt: s.store.Timestamp(template.CreatedAt), + } + if template.SourceGuildID != nil { + payload.SourceGuildID = formatSnowflake(*template.SourceGuildID) + } + var structure userGuildTemplateStructure + if err := json.Unmarshal(template.Structure, &structure); err != nil { + // Битый шаблон не должен ломать список: отдаём его без предпросмотра. + return payload + } + fillTemplatePreview(&payload, structure) + return payload +} + +// fillTemplatePreview дополняет предпросмотр по структуре шаблона. Роли +// «Администратор» и «Пользователь» уже перечислены: они создаются для каждого +// сервера (AGENT.md 6.3), поэтому повторно их не добавляем. +func fillTemplatePreview(payload *guildTemplatePayload, structure userGuildTemplateStructure) { + seen := make(map[string]bool, len(payload.Roles)) + for _, name := range payload.Roles { + seen[name] = true + } + for _, role := range structure.Roles { + name := strings.TrimSpace(role.Name) + if name == "" || seen[name] { + continue + } + seen[name] = true + payload.Roles = append(payload.Roles, name) + } + categories := make(map[string]bool, 4) + for _, channel := range structure.Channels { + switch store.ChannelType(channel.Type) { + case store.ChannelText: + payload.TextCount++ + case store.ChannelVoice: + payload.VoiceCount++ + case store.ChannelCategory: + if !categories[channel.Name] { + categories[channel.Name] = true + payload.Categories = append(payload.Categories, channel.Name) + } + case store.ChannelDM: + // Личных бесед в шаблонах нет. + } + } +} diff --git a/internal/server/guild_templates_test.go b/internal/server/guild_templates_test.go index 2085909..28fb622 100644 --- a/internal/server/guild_templates_test.go +++ b/internal/server/guild_templates_test.go @@ -1,12 +1,18 @@ package server import ( + "encoding/json" "net/http" + "strconv" + "strings" "testing" + + "glchat/internal/store" ) // Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру -// комнат и ролей без сообщений, неизвестный шаблон отклоняется. +// комнат и ролей без сообщений, неизвестный шаблон отклоняется, личные шаблоны +// приватны и воспроизводят структуру своего сервера (Фаза 7). func TestGuildTemplatesCatalog(t *testing.T) { srv, _ := newTestServer(t) @@ -190,3 +196,459 @@ func TestGuildFromTemplateCreatesNoDuplicates(t *testing.T) { t.Fatalf("комнат %d, ожидалось 7", len(list.Channels)) } } + +// guildChannels читает комнаты сервера: имя, тип, родитель, медленный режим и +// лимит участников — по ним проверяется воспроизведение структуры. +type guildChannelView struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + ParentID string `json:"parent_id"` + SlowmodeSeconds int `json:"slowmode_seconds"` + UserLimit int `json:"user_limit"` +} + +func readGuildChannels(t *testing.T, srv *Server, guildID string, user *http.Cookie) []guildChannelView { + t.Helper() + rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", user) + if rec.Code != http.StatusOK { + t.Fatalf("GET /guilds/%s/channels = %d (%s)", guildID, rec.Code, rec.Body.String()) + } + return decodeResponse[struct { + Channels []guildChannelView `json:"channels"` + }](t, rec).Channels +} + +// guildRoleView — роль глазами клиента: цвет и права строкой. +type guildRoleView struct { + ID string `json:"id"` + Name string `json:"name"` + Color int64 `json:"color"` + Permissions string `json:"permissions"` + Mentionable bool `json:"mentionable"` +} + +func readGuildRoles(t *testing.T, srv *Server, guildID string, user *http.Cookie) []guildRoleView { + t.Helper() + rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", user) + if rec.Code != http.StatusOK { + t.Fatalf("GET /guilds/%s/roles = %d (%s)", guildID, rec.Code, rec.Body.String()) + } + return decodeResponse[struct { + Roles []guildRoleView `json:"roles"` + }](t, rec).Roles +} + +// TestUserGuildTemplateRoundTrip сохраняет структуру сервера личным шаблоном и +// создаёт по нему новый сервер: роли с правами и цветом, категории и комнаты с +// типами и настройками воспроизводятся (AGENT.md 7.3, Фаза 7). +func TestUserGuildTemplateRoundTrip(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_save", "tpl-save@example.com") + source := createGuildForTest(t, srv, owner, "Источник") + + // Своя роль с цветом и правами: в шаблон она должна попасть целиком. + roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+source+"/roles", + `{"name":"Модератор","color":16711680,"permissions":"KICK_MEMBERS|MANAGE_MESSAGES","hoist":true,"mentionable":true}`, owner) + if roleRec.Code != http.StatusOK { + t.Fatalf("создание роли = %d (%s)", roleRec.Code, roleRec.Body.String()) + } + category := createChannelForTest(t, srv, owner, source, + `{"name":"Информация","type":"category"}`) + createChannelForTest(t, srv, owner, source, + `{"name":"правила","type":"text","parent_id":"`+category+`","slowmode_seconds":30}`) + createChannelForTest(t, srv, owner, source, + `{"name":"Голосовой","type":"voice","parent_id":"`+category+`","user_limit":5}`) + + saved := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"Мой шаблон","description":"Структура источника"}`, owner) + if saved.Code != http.StatusOK { + t.Fatalf("сохранение шаблона = %d (%s)", saved.Code, saved.Body.String()) + } + templatePayload := decodeResponse[struct { + Template struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Roles []string `json:"roles"` + TextCount int `json:"text_channels"` + VoiceCount int `json:"voice_channels"` + Categories []string `json:"categories"` + CreatedAt string `json:"created_at"` + SourceGuild string `json:"source_guild_id"` + } `json:"template"` + }](t, saved).Template + if templatePayload.ID == "" || templatePayload.Name != "Мой шаблон" { + t.Fatalf("неожиданный шаблон: %+v", templatePayload) + } + if templatePayload.CreatedAt == "" || templatePayload.SourceGuild != source { + t.Fatalf("created_at/source_guild_id = %q/%q", templatePayload.CreatedAt, templatePayload.SourceGuild) + } + if !contains(templatePayload.Roles, "Модератор") { + t.Fatalf("роль шаблона потеряна: %v", templatePayload.Roles) + } + if templatePayload.TextCount != 2 || templatePayload.VoiceCount != 1 { + t.Fatalf("комнат в шаблоне: text=%d voice=%d, ожидалось 2/1", templatePayload.TextCount, templatePayload.VoiceCount) + } + if !contains(templatePayload.Categories, "Информация") { + t.Fatalf("категория шаблона потеряна: %v", templatePayload.Categories) + } + + // Формат structure_json: права — строкой как в API ролей, комната ссылается + // на категорию по имени (идентификаторы в новом сервере другие). + templateID, err := strconv.ParseUint(templatePayload.ID, 10, 64) + if err != nil { + t.Fatalf("идентификатор шаблона %q: %v", templatePayload.ID, err) + } + stored, err := srv.store.GetUserGuildTemplate(t.Context(), templateID) + if err != nil { + t.Fatalf("GetUserGuildTemplate: %v", err) + } + var structure userGuildTemplateStructure + if err := json.Unmarshal(stored.Structure, &structure); err != nil { + t.Fatalf("разбор structure_json: %v (%s)", err, stored.Structure) + } + var storedRole *userGuildTemplateRole + for i := range structure.Roles { + if structure.Roles[i].Name == "Модератор" { + storedRole = &structure.Roles[i] + } + } + if storedRole == nil { + t.Fatalf("в structure_json нет роли: %s", stored.Structure) + } + if storedRole.Color != 16711680 || !strings.Contains(storedRole.Permissions, "KICK_MEMBERS") { + t.Errorf("роль в structure_json: %+v", *storedRole) + } + var storedChannel *userGuildTemplateChannel + for i := range structure.Channels { + if structure.Channels[i].Name == "правила" { + storedChannel = &structure.Channels[i] + } + } + if storedChannel == nil { + t.Fatalf("в structure_json нет комнаты: %s", stored.Structure) + } + if storedChannel.Type != "text" || storedChannel.Category != "Информация" || storedChannel.SlowmodeSeconds != 30 { + t.Errorf("комната в structure_json: %+v", *storedChannel) + } + + // Каталог встроенных шаблонов личными не засоряется. + catalog := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates", "", owner) + if count := len(decodeResponse[struct { + Templates []struct { + ID string `json:"id"` + } `json:"templates"` + }](t, catalog).Templates); count != 3 { + t.Fatalf("встроенных шаблонов %d, ожидалось 3", count) + } + + mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", owner) + if count := len(decodeResponse[struct { + Templates []struct { + ID string `json:"id"` + } `json:"templates"` + }](t, mine).Templates); count != 1 { + t.Fatalf("своих шаблонов %d, ожидалось 1", count) + } + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", + `{"name":"Копия","template_id":"personal:`+templatePayload.ID+`"}`, owner) + if created.Code != http.StatusOK { + t.Fatalf("создание сервера по личному шаблону = %d (%s)", created.Code, created.Body.String()) + } + copyID := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"guild"` + }](t, created).Guild.ID + if copyID == "" || copyID == source { + t.Fatalf("сервер не создан: %q", copyID) + } + + roles := readGuildRoles(t, srv, copyID, owner) + var copied *guildRoleView + for i := range roles { + if roles[i].Name == "Модератор" { + copied = &roles[i] + } + } + if copied == nil { + t.Fatalf("роль «Модератор» не создана: %+v", roles) + } + if copied.Color != 16711680 { + t.Errorf("цвет роли = %d, ожидался 16711680", copied.Color) + } + if !strings.Contains(copied.Permissions, "KICK_MEMBERS") || !strings.Contains(copied.Permissions, "MANAGE_MESSAGES") { + t.Errorf("права роли = %q", copied.Permissions) + } + if !copied.Mentionable { + t.Error("роль не упоминаемая") + } + // Роли по умолчанию остаются на месте и не дублируются. + for _, name := range []string{"Администратор", "Пользователь"} { + seen := 0 + for _, role := range roles { + if role.Name == name { + seen++ + } + } + if seen != 1 { + t.Errorf("роль %q создана %d раз, ожидалось 1", name, seen) + } + } + + channels := readGuildChannels(t, srv, copyID, owner) + byName := map[string]guildChannelView{} + for _, channel := range channels { + byName[channel.Name] = channel + } + copiedCategory, ok := byName["Информация"] + if !ok || copiedCategory.Type != "category" { + t.Fatalf("категория не создана: %+v", channels) + } + copiedText, ok := byName["правила"] + if !ok || copiedText.Type != "text" { + t.Fatalf("текстовая комната не создана: %+v", channels) + } + if copiedText.ParentID != copiedCategory.ID { + t.Errorf("текстовая комната вне категории: %+v", copiedText) + } + if copiedText.SlowmodeSeconds != 30 { + t.Errorf("медленный режим = %d, ожидался 30", copiedText.SlowmodeSeconds) + } + copiedVoice, ok := byName["Голосовой"] + if !ok || copiedVoice.Type != "voice" { + t.Fatalf("голосовая комната не создана: %+v", channels) + } + if copiedVoice.UserLimit != 5 { + t.Errorf("лимит участников = %d, ожидался 5", copiedVoice.UserLimit) + } + + // Создание сервера по шаблону пишется в аудит с причиной template=personal:. + copyGuildID, err := strconv.ParseUint(copyID, 10, 64) + if err != nil { + t.Fatalf("идентификатор сервера %q: %v", copyID, err) + } + entries, err := srv.store.ListAuditLog(t.Context(), copyGuildID, 20) + if err != nil { + t.Fatalf("ListAuditLog: %v", err) + } + found := false + for _, entry := range entries { + if entry.Action == "guild.create" && strings.Contains(entry.Reason, "template=personal:"+templatePayload.ID) { + found = true + } + } + if !found { + t.Fatalf("в аудите нет причины template=personal:%s: %+v", templatePayload.ID, entries) + } +} + +// TestUserGuildTemplatePrivacy: чужой личный шаблон не виден в списке, не +// удаляется (404) и не может быть использован при создании сервера (422). +func TestUserGuildTemplatePrivacy(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_priv_owner", "tpl-priv-owner@example.com") + other := registerAndLogin(t, srv, "tpl_priv_other", "tpl-priv-other@example.com") + source := createGuildForTest(t, srv, owner, "Закрытый") + + saved := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"Секрет"}`, owner) + if saved.Code != http.StatusOK { + t.Fatalf("сохранение шаблона = %d (%s)", saved.Code, saved.Body.String()) + } + templateID := decodeResponse[struct { + Template struct { + ID string `json:"id"` + } `json:"template"` + }](t, saved).Template.ID + + mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", other) + if count := len(decodeResponse[struct { + Templates []struct { + ID string `json:"id"` + } `json:"templates"` + }](t, mine).Templates); count != 0 { + t.Fatalf("чужой шаблон виден в списке: %d", count) + } + + deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guild-templates/"+templateID, "", other) + if deleted.Code != http.StatusNotFound { + t.Fatalf("удаление чужого шаблона = %d (%s)", deleted.Code, deleted.Body.String()) + } + if code := errorCodeOf(t, deleted); code != "not_found" { + t.Fatalf("код ошибки = %q, ожидался not_found", code) + } + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", + `{"name":"Чужой","template_id":"personal:`+templateID+`"}`, other) + if created.Code != http.StatusUnprocessableEntity { + t.Fatalf("создание по чужому шаблону = %d (%s)", created.Code, created.Body.String()) + } + if code := errorCodeOf(t, created); code != "validation.failed" { + t.Fatalf("код ошибки = %q, ожидался validation.failed", code) + } + + // Владелец по-прежнему видит и удаляет свой шаблон. + ok := doJSON(t, srv, http.MethodDelete, "/api/v1/guild-templates/"+templateID, "", owner) + if ok.Code != http.StatusOK { + t.Fatalf("удаление своего шаблона = %d (%s)", ok.Code, ok.Body.String()) + } + if mine := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates/mine", "", owner); len(decodeResponse[struct { + Templates []struct { + ID string `json:"id"` + } `json:"templates"` + }](t, mine).Templates) != 0 { + t.Fatal("шаблон не удалён") + } +} + +// TestUserGuildTemplateRequiresManageGuild: участник без MANAGE_GUILD не может +// сохранить шаблон с чужого сервера. +func TestUserGuildTemplateRequiresManageGuild(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_perm_owner", "tpl-perm-owner@example.com") + member := registerAndLogin(t, srv, "tpl_perm_member", "tpl-perm-member@example.com") + source := createGuildForTest(t, srv, owner, "Права") + + invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+source+"/invites", `{}`, owner) + if invite.Code != http.StatusOK { + t.Fatalf("создание приглашения = %d (%s)", invite.Code, invite.Body.String()) + } + code := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + } `json:"invite"` + }](t, invite).Invite.Code + if joined := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member); joined.Code != http.StatusOK { + t.Fatalf("вступление по приглашению = %d (%s)", joined.Code, joined.Body.String()) + } + + denied := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"Нельзя"}`, member) + if denied.Code != http.StatusForbidden { + t.Fatalf("сохранение без MANAGE_GUILD = %d (%s)", denied.Code, denied.Body.String()) + } + if code := errorCodeOf(t, denied); code != "perm.denied" { + t.Fatalf("код ошибки = %q, ожидался perm.denied", code) + } +} + +// TestUserGuildTemplateLimitReached: двадцать первый личный шаблон отклоняется +// с кодом template.limit_reached (409). +func TestUserGuildTemplateLimitReached(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_limit", "tpl-limit@example.com") + source := createGuildForTest(t, srv, owner, "Лимит") + account, err := srv.auth.UserByEmail(t.Context(), "tpl-limit@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + for i := 0; i < store.MaxUserGuildTemplates; i++ { + if _, err := srv.store.CreateUserGuildTemplate(t.Context(), store.CreateUserGuildTemplateParams{ + OwnerID: account.ID, + Name: "Заполнение", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }); err != nil { + t.Fatalf("шаблон %d: %v", i, err) + } + } + + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"Лишний"}`, owner) + if rec.Code != http.StatusConflict { + t.Fatalf("превышение лимита = %d (%s)", rec.Code, rec.Body.String()) + } + if code := errorCodeOf(t, rec); code != "template.limit_reached" { + t.Fatalf("код ошибки = %q, ожидался template.limit_reached", code) + } +} + +// TestInstanceAdminSavesForeignGuildTemplate: у администратора инстанса +// MANAGE_GUILD есть на любом сервере (AGENT.md 7.19). +func TestInstanceAdminSavesForeignGuildTemplate(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_admin_owner", "tpl-admin-owner@example.com") + admin := registerAndLogin(t, srv, "tpl_admin", "tpl-admin@example.com") + promoteAdmin(t, srv, "tpl-admin@example.com") + source := createGuildForTest(t, srv, owner, "Чужой сервер") + + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"От админа"}`, admin) + if rec.Code != http.StatusOK { + t.Fatalf("админ инстанса не сохранил шаблон = %d (%s)", rec.Code, rec.Body.String()) + } + template := decodeResponse[struct { + Template struct { + ID string `json:"id"` + SourceGuild string `json:"source_guild_id"` + } `json:"template"` + }](t, rec).Template + if template.SourceGuild != source { + t.Fatalf("source_guild_id = %q, ожидался %q", template.SourceGuild, source) + } + if created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", + `{"name":"По чужому","template_id":"personal:`+template.ID+`"}`, admin); created.Code != http.StatusOK { + t.Fatalf("создание сервера админом = %d (%s)", created.Code, created.Body.String()) + } +} + +// TestUserGuildTemplateStructureLimit: слишком большая структура (больше 100 +// комнат) в шаблон не попадает — 422 validation.failed. +func TestUserGuildTemplateStructureLimit(t *testing.T) { + srv, _ := newTestServer(t) + owner := registerAndLogin(t, srv, "tpl_big", "tpl-big@example.com") + source := createGuildForTest(t, srv, owner, "Большой") + guildID, err := strconv.ParseUint(source, 10, 64) + if err != nil { + t.Fatalf("идентификатор сервера %q: %v", source, err) + } + // В сервере уже есть общая комната, поэтому лимит превышен на одну. + for i := 0; i < maxUserGuildTemplateChannels; i++ { + if _, err := srv.store.CreateChannel(t.Context(), store.CreateChannelParams{ + GuildID: &guildID, Type: store.ChannelText, + Name: "комната-" + strconv.Itoa(i), Position: i + 1, + }); err != nil { + t.Fatalf("комната %d: %v", i, err) + } + } + + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guild-templates", + `{"guild_id":"`+source+`","name":"Слишком большой"}`, owner) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("слишком большая структура = %d (%s)", rec.Code, rec.Body.String()) + } + if code := errorCodeOf(t, rec); code != "validation.failed" { + t.Fatalf("код ошибки = %q, ожидался validation.failed", code) + } +} + +// createGuildForTest создаёт сервер без шаблона и возвращает его id. +func createGuildForTest(t *testing.T, srv *Server, user *http.Cookie, name string) string { + t.Helper() + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, user) + if rec.Code != http.StatusOK { + t.Fatalf("создание сервера %q = %d (%s)", name, rec.Code, rec.Body.String()) + } + return decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, rec).Guild.ID +} + +// createChannelForTest создаёт комнату и возвращает её id. +func createChannelForTest(t *testing.T, srv *Server, user *http.Cookie, guildID, body string) string { + t.Helper() + rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels", body, user) + if rec.Code != http.StatusOK { + t.Fatalf("создание комнаты = %d (%s)", rec.Code, rec.Body.String()) + } + return decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, rec).Channel.ID +} diff --git a/internal/store/guild_templates.go b/internal/store/guild_templates.go new file mode 100644 index 0000000..6cf93d1 --- /dev/null +++ b/internal/store/guild_templates.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" +) + +// Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): владелец сохраняет +// структуру своего сервера — роли с правами и цветом, категории и комнаты — +// и создаёт по ней новые серверы. Шаблон приватный: список, число и удаление +// всегда фильтруются по owner_id, а чужой шаблон для остальных выглядит +// несуществующим (AGENT.md 9.7). + +// Пределы хранения личных шаблонов: не больше 20 шаблонов на пользователя и +// не больше 16 КиБ на structure_json. Те же значения проверяет HTTP-слой, +// чтобы вернуть понятную ошибку (template.limit_reached), а здесь они — +// жёсткая защита базы от обхода лимита гонкой двух запросов. +const ( + MaxUserGuildTemplates = 20 + MaxUserGuildTemplateBytes = 16 << 10 +) + +var ( + ErrTemplateLimitReached = errors.New("personal guild template limit reached") + ErrTemplateTooLarge = errors.New("personal guild template is too large") +) + +// UserGuildTemplate — личный шаблон структуры сервера. +type UserGuildTemplate struct { + ID uint64 + OwnerID uint64 + Name string + Description string + Structure json.RawMessage + SourceGuildID *uint64 + CreatedAt time.Time +} + +// CreateUserGuildTemplateParams — параметры нового шаблона; Structure — уже +// готовый JSON структуры (формат описан в internal/server/guild_templates.go). +type CreateUserGuildTemplateParams struct { + ID uint64 + OwnerID uint64 + Name string + Description string + Structure json.RawMessage + SourceGuildID *uint64 +} + +const userGuildTemplateColumns = `id, owner_id, name, description, structure_json, + source_guild_id, created_at` + +// CreateUserGuildTemplate сохраняет шаблон: проверка лимитов и вставка идут +// одной транзакцией, иначе два одновременных запроса обошли бы лимит. +func (s *Store) CreateUserGuildTemplate(ctx context.Context, params CreateUserGuildTemplateParams) (*UserGuildTemplate, error) { + if len(params.Structure) == 0 { + params.Structure = json.RawMessage("{}") + } + if len(params.Structure) > MaxUserGuildTemplateBytes { + return nil, ErrTemplateTooLarge + } + if params.ID == 0 { + params.ID = s.NextID() + } + err := s.InTx(ctx, func(tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(params.OwnerID), + ).Scan(&count); err != nil { + return err + } + if count >= MaxUserGuildTemplates { + return ErrTemplateLimitReached + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO guild_templates (id, owner_id, name, description, structure_json, source_guild_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + mustID(params.ID), mustID(params.OwnerID), params.Name, params.Description, + string(params.Structure), nullableID(params.SourceGuildID), s.Now()) + return err + }) + if err != nil { + return nil, mapError(err) + } + return s.GetUserGuildTemplate(ctx, params.ID) +} + +// ListUserGuildTemplates перечисляет личные шаблоны владельца: свежие первыми +// (индекс guild_templates_owner_idx). +func (s *Store) ListUserGuildTemplates(ctx context.Context, ownerID uint64) ([]UserGuildTemplate, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+userGuildTemplateColumns+` FROM guild_templates + WHERE owner_id = ? ORDER BY id DESC`, mustID(ownerID)) + if err != nil { + return nil, err + } + defer rows.Close() + + templates := make([]UserGuildTemplate, 0, 8) + for rows.Next() { + template, err := scanUserGuildTemplate(rows) + if err != nil { + return nil, err + } + templates = append(templates, *template) + } + return templates, rows.Err() +} + +// GetUserGuildTemplate отдаёт шаблон по идентификатору. Приватность проверяет +// вызывающий код сравнением OwnerID с текущим пользователем: чужой шаблон +// должен выглядеть как отсутствующий, а не подтверждать своё существование. +func (s *Store) GetUserGuildTemplate(ctx context.Context, id uint64) (*UserGuildTemplate, error) { + return scanUserGuildTemplate(s.reader.QueryRowContext(ctx, + `SELECT `+userGuildTemplateColumns+` FROM guild_templates WHERE id = ?`, mustID(id))) +} + +// DeleteUserGuildTemplate удаляет шаблон владельца; false — шаблона нет или он +// принадлежит другому пользователю (существование не подтверждаем). +func (s *Store) DeleteUserGuildTemplate(ctx context.Context, id, ownerID uint64) (bool, error) { + result, err := s.writer.ExecContext(ctx, + `DELETE FROM guild_templates WHERE id = ? AND owner_id = ?`, mustID(id), mustID(ownerID)) + if err != nil { + return false, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} + +// CountUserGuildTemplates считает личные шаблоны владельца — для проверки +// лимита перед сохранением. +func (s *Store) CountUserGuildTemplates(ctx context.Context, ownerID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guild_templates WHERE owner_id = ?`, mustID(ownerID)).Scan(&count) + return count, err +} + +func scanUserGuildTemplate(scanner interface{ Scan(...any) error }) (*UserGuildTemplate, error) { + var ( + template UserGuildTemplate + structure string + sourceGuildID sql.NullInt64 + createdAt string + ) + err := scanner.Scan(&template.ID, &template.OwnerID, &template.Name, &template.Description, + &structure, &sourceGuildID, &createdAt) + if err != nil { + return nil, mapError(err) + } + template.Structure = json.RawMessage(structure) + template.SourceGuildID = optionalID(sourceGuildID) + template.CreatedAt = parseTimestamp(createdAt) + return &template, nil +} diff --git a/internal/store/guild_templates_test.go b/internal/store/guild_templates_test.go new file mode 100644 index 0000000..1330a45 --- /dev/null +++ b/internal/store/guild_templates_test.go @@ -0,0 +1,242 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "glchat/internal/database" +) + +// newUserGuildTemplateTestStore открывает временную БД со всеми миграциями и +// заводит двух пользователей: шаблоны ссылаются на владельца внешним ключом, +// а приватность проверяется на втором аккаунте. +func newUserGuildTemplateTestStore(t *testing.T) (*Store, uint64, uint64) { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("открыть тестовую БД: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("закрыть тестовую БД: %v", err) + } + }) + st := New(db) + owner, err := st.CreateUser(ctx, CreateUserParams{ + Username: "tpl_owner", + EmailEnc: "enc-owner", + EmailIndex: "idx-owner", + PasswordHash: "hash", + }) + if err != nil { + t.Fatalf("создать владельца: %v", err) + } + other, err := st.CreateUser(ctx, CreateUserParams{ + Username: "tpl_other", + EmailEnc: "enc-other", + EmailIndex: "idx-other", + PasswordHash: "hash", + }) + if err != nil { + t.Fatalf("создать второго пользователя: %v", err) + } + return st, owner.ID, other.ID +} + +func TestUserGuildTemplateLifecycle(t *testing.T) { + ctx := context.Background() + st, ownerID, _ := newUserGuildTemplateTestStore(t) + + guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Источник", OwnerID: ownerID}) + if err != nil { + t.Fatalf("CreateGuild: %v", err) + } + structure := json.RawMessage(`{"roles":[{"name":"Модератор","color":16711680,"permissions":"KICK_MEMBERS","position":50,"hoist":true,"mentionable":true}],"channels":[{"name":"Информация","type":"category","position":0}]}`) + + created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Мой шаблон", + Description: "Структура", + Structure: structure, + SourceGuildID: &guild.ID, + }) + if err != nil { + t.Fatalf("CreateUserGuildTemplate: %v", err) + } + if created.ID == 0 || created.OwnerID != ownerID || created.Name != "Мой шаблон" { + t.Fatalf("неожиданный шаблон: %+v", created) + } + if created.SourceGuildID == nil || *created.SourceGuildID != guild.ID { + t.Fatalf("source_guild_id = %v, ожидался %d", created.SourceGuildID, guild.ID) + } + if string(created.Structure) != string(structure) { + t.Fatalf("structure_json = %s", created.Structure) + } + if created.CreatedAt.IsZero() { + t.Fatal("created_at не заполнен") + } + + fetched, err := st.GetUserGuildTemplate(ctx, created.ID) + if err != nil { + t.Fatalf("GetUserGuildTemplate: %v", err) + } + if fetched.Name != created.Name || fetched.Description != created.Description { + t.Fatalf("прочитан другой шаблон: %+v", fetched) + } + + second, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Второй", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }) + if err != nil { + t.Fatalf("CreateUserGuildTemplate (второй): %v", err) + } + + list, err := st.ListUserGuildTemplates(ctx, ownerID) + if err != nil { + t.Fatalf("ListUserGuildTemplates: %v", err) + } + if len(list) != 2 { + t.Fatalf("шаблонов %d, ожидалось 2", len(list)) + } + // Свежие — первыми: список сортируется по id DESC. + if list[0].ID != second.ID || list[1].ID != created.ID { + t.Fatalf("порядок шаблонов неверен: %d, %d", list[0].ID, list[1].ID) + } + + count, err := st.CountUserGuildTemplates(ctx, ownerID) + if err != nil { + t.Fatalf("CountUserGuildTemplates: %v", err) + } + if count != 2 { + t.Fatalf("CountUserGuildTemplates = %d, ожидалось 2", count) + } + + deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, ownerID) + if err != nil { + t.Fatalf("DeleteUserGuildTemplate: %v", err) + } + if !deleted { + t.Fatal("свой шаблон не удалён") + } + if _, err := st.GetUserGuildTemplate(ctx, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("удалённый шаблон читается: %v", err) + } + deleted, err = st.DeleteUserGuildTemplate(ctx, created.ID, ownerID) + if err != nil { + t.Fatalf("повторное удаление: %v", err) + } + if deleted { + t.Fatal("повторное удаление вернуло true") + } +} + +// Приватность: чужой шаблон не виден в списке и не удаляется, его владелец +// продолжает им пользоваться. +func TestUserGuildTemplatePrivacy(t *testing.T) { + ctx := context.Background() + st, ownerID, otherID := newUserGuildTemplateTestStore(t) + + created, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Секретный", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }) + if err != nil { + t.Fatalf("CreateUserGuildTemplate: %v", err) + } + + list, err := st.ListUserGuildTemplates(ctx, otherID) + if err != nil { + t.Fatalf("ListUserGuildTemplates: %v", err) + } + if len(list) != 0 { + t.Fatalf("чужой шаблон виден в списке: %+v", list) + } + count, err := st.CountUserGuildTemplates(ctx, otherID) + if err != nil { + t.Fatalf("CountUserGuildTemplates: %v", err) + } + if count != 0 { + t.Fatalf("чужой шаблон посчитан: %d", count) + } + deleted, err := st.DeleteUserGuildTemplate(ctx, created.ID, otherID) + if err != nil { + t.Fatalf("DeleteUserGuildTemplate: %v", err) + } + if deleted { + t.Fatal("чужой шаблон удалён") + } + if _, err := st.GetUserGuildTemplate(ctx, created.ID); err != nil { + t.Fatalf("шаблон владельца пропал: %v", err) + } +} + +func TestUserGuildTemplateLimit(t *testing.T) { + ctx := context.Background() + st, ownerID, otherID := newUserGuildTemplateTestStore(t) + + for i := 0; i < MaxUserGuildTemplates; i++ { + if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Шаблон", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }); err != nil { + t.Fatalf("шаблон %d: %v", i, err) + } + } + _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Лишний", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }) + if !errors.Is(err, ErrTemplateLimitReached) { + t.Fatalf("превышение лимита = %v, ожидалось ErrTemplateLimitReached", err) + } + count, err := st.CountUserGuildTemplates(ctx, ownerID) + if err != nil { + t.Fatalf("CountUserGuildTemplates: %v", err) + } + if count != MaxUserGuildTemplates { + t.Fatalf("после отказа шаблонов %d, ожидалось %d", count, MaxUserGuildTemplates) + } + // Лимит считается по владельцу: у второго пользователя место свободно. + if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: otherID, + Name: "Свой", + Structure: json.RawMessage(`{"roles":[],"channels":[]}`), + }); err != nil { + t.Fatalf("шаблон второго пользователя: %v", err) + } +} + +func TestUserGuildTemplateTooLarge(t *testing.T) { + ctx := context.Background() + st, ownerID, _ := newUserGuildTemplateTestStore(t) + + oversized := json.RawMessage(`{"note":"` + strings.Repeat("x", MaxUserGuildTemplateBytes) + `"}`) + if _, err := st.CreateUserGuildTemplate(ctx, CreateUserGuildTemplateParams{ + OwnerID: ownerID, + Name: "Большой", + Structure: oversized, + }); !errors.Is(err, ErrTemplateTooLarge) { + t.Fatalf("слишком большая структура = %v, ожидалось ErrTemplateTooLarge", err) + } + count, err := st.CountUserGuildTemplates(ctx, ownerID) + if err != nil { + t.Fatalf("CountUserGuildTemplates: %v", err) + } + if count != 0 { + t.Fatalf("отклонённый шаблон сохранён: %d", count) + } +}