From 4fb35091287bc40ad5cc3ab04fbf2f3be58cd4b1 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 22:38:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(moderation):=20=D0=B1=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0,=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC-=D0=B0=D1=83=D1=82=D1=8B=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 4 (AGENT.md 7.17, 7.18): модерация участников. - миграция 00011: таблица `guild_bans` (бан переживает исключение участника); - `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`: исключает участника, отключает его от голосовой комнаты, пишет системное сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`; - `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список с автором и причиной (только модераторам); - вход по приглашению для забаненного отклоняется с `guild.banned`; - тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его; - тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие тайм-ауты не показываются в профиле участника; - журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`; - тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия, блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита. --- .../database/migrations/00011_guild_bans.sql | 16 + internal/permissions/cache.go | 20 +- internal/permissions/channel.go | 8 +- internal/server/api_bans.go | 188 ++++++++++ internal/server/api_guilds.go | 44 ++- internal/server/api_invites.go | 18 + internal/server/moderation_test.go | 324 ++++++++++++++++++ internal/server/server.go | 1 + internal/store/bans.go | 152 ++++++++ internal/store/instance.go | 13 - 10 files changed, 756 insertions(+), 28 deletions(-) create mode 100644 internal/database/migrations/00011_guild_bans.sql create mode 100644 internal/server/api_bans.go create mode 100644 internal/server/moderation_test.go create mode 100644 internal/store/bans.go diff --git a/internal/database/migrations/00011_guild_bans.sql b/internal/database/migrations/00011_guild_bans.sql new file mode 100644 index 0000000..0362a44 --- /dev/null +++ b/internal/database/migrations/00011_guild_bans.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Баны сервера (AGENT.md 7.17): отдельная таблица, чтобы бан переживал +-- исключение участника и запрещал повторный вход по приглашению. +CREATE TABLE guild_bans ( + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + actor_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + reason TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (guild_id, user_id) +); +CREATE INDEX guild_bans_user_idx ON guild_bans (user_id); + +-- +goose Down +DROP INDEX guild_bans_user_idx; +DROP TABLE guild_bans; diff --git a/internal/permissions/cache.go b/internal/permissions/cache.go index 777f692..fcc67a3 100644 --- a/internal/permissions/cache.go +++ b/internal/permissions/cache.go @@ -21,6 +21,9 @@ type guildKey struct { } type channelKey struct { + // guildID нужен, чтобы сброс сервера (роли, тайм-аут, оверрайды) сбрасывал + // и кэш его комнат: иначе участник с тайм-аутом продолжал писать. + guildID uint64 channelID uint64 userID uint64 } @@ -69,10 +72,10 @@ func (c *Cache) PutGuild(guildID, userID uint64, value Resolved) { c.evictLocked() } -func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) { +func (c *Cache) GetChannel(guildID, channelID, userID uint64) (Resolved, bool) { c.mu.Lock() defer c.mu.Unlock() - element, ok := c.channels[channelKey{channelID: channelID, userID: userID}] + element, ok := c.channels[channelKey{guildID: guildID, channelID: channelID, userID: userID}] if !ok { return Resolved{}, false } @@ -80,10 +83,10 @@ func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) { return element.Value.(*cacheEntry).value, true } -func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) { +func (c *Cache) PutChannel(guildID, channelID, userID uint64, value Resolved) { c.mu.Lock() defer c.mu.Unlock() - key := channelKey{channelID: channelID, userID: userID} + key := channelKey{guildID: guildID, channelID: channelID, userID: userID} if element, ok := c.channels[key]; ok { element.Value.(*cacheEntry).value = value c.order.MoveToFront(element) @@ -94,7 +97,8 @@ func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) { c.evictLocked() } -// InvalidateGuild сбрасывает права всех пользователей сервера. +// InvalidateGuild сбрасывает права всех пользователей сервера, включая кэш +// его комнат: роли, тайм-аут и оверрайды влияют и на права в комнатах. func (c *Cache) InvalidateGuild(guildID uint64) { c.mu.Lock() defer c.mu.Unlock() @@ -104,6 +108,12 @@ func (c *Cache) InvalidateGuild(guildID uint64) { delete(c.items, key) } } + for key, element := range c.channels { + if key.guildID == guildID { + c.order.Remove(element) + delete(c.channels, key) + } + } } // InvalidateChannel сбрасывает кэш комнаты. diff --git a/internal/permissions/channel.go b/internal/permissions/channel.go index e97795d..f753fb4 100644 --- a/internal/permissions/channel.go +++ b/internal/permissions/channel.go @@ -8,7 +8,7 @@ import ( // Channel вычисляет права пользователя в конкретной комнате: базовые права // сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2). func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) (Resolved, error) { - if cached, ok := c.cache.GetChannel(channelID, userID); ok { + if cached, ok := c.cache.GetChannel(guildID, channelID, userID); ok { cached.IsInstanceAdmin = instanceAdmin return cached, nil } @@ -20,11 +20,11 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin resolved.Channel = resolved.Guild if !resolved.IsMember && !resolved.IsInstanceAdmin { // Не участник не видит ничего, даже если есть персональный оверрайд. - c.cache.PutChannel(channelID, userID, resolved) + c.cache.PutChannel(guildID, channelID, userID, resolved) return resolved, nil } if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) { - c.cache.PutChannel(channelID, userID, resolved) + c.cache.PutChannel(guildID, channelID, userID, resolved) return resolved, nil } @@ -78,6 +78,6 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin } } resolved.Channel = (computed &^ userDeny) | userAllow - c.cache.PutChannel(channelID, userID, resolved) + c.cache.PutChannel(guildID, channelID, userID, resolved) return resolved, nil } diff --git a/internal/server/api_bans.go b/internal/server/api_bans.go new file mode 100644 index 0000000..3010e54 --- /dev/null +++ b/internal/server/api_bans.go @@ -0,0 +1,188 @@ +package server + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Баны сервера (AGENT.md 7.17): список, постановка и снятие. Бан исключает +// участника, запрещает повторный вход по приглашению и пишется в аудит. + +type guildBanPayload struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name,omitempty"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + Reason string `json:"reason,omitempty"` + CreatedAt string `json:"created_at"` +} + +type guildBanListOutput struct { + Body struct { + Bans []guildBanPayload `json:"bans"` + } +} + +// registerBanRoutes описывает бан-лист сервера. +func (s *Server) registerBanRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildBans", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/bans", + Summary: "Забаненные участники сервера", + Tags: []string{"Bans"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Limit int `query:"limit" default:"100" minimum:"1" maximum:"200"` + }, + ) (*guildBanListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.BanMembers) + if err != nil { + return nil, err + } + bans, err := s.store.ListGuildBans(ctx, guildID, input.Limit) + if err != nil { + return nil, humaError(err) + } + output := &guildBanListOutput{} + output.Body.Bans = make([]guildBanPayload, 0, len(bans)) + for _, ban := range bans { + output.Body.Bans = append(output.Body.Bans, banPayload(ban)) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "banGuildMember", + Method: http.MethodPut, + Path: "/guilds/{guild_id}/bans/{user_id}", + Summary: "Забанить участника", + Tags: []string{"Bans"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + Body struct { + Reason string `json:"reason,omitempty" maxLength:"400"` + } + }, + ) (*guildBanListOutput, error) { + actor, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.BanMembers) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if err := s.checkMemberHierarchy(ctx, guildID, actor, resolved, targetID); err != nil { + return nil, err + } + reason := strings.TrimSpace(input.Body.Reason) + if err := s.store.BanGuildMember(ctx, guildID, targetID, actor.ID, reason); err != nil { + return nil, humaError(err) + } + // Исключаем участника и отключаем его от голосовой комнаты, если он там. + if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil { + return nil, humaError(err) + } + if state, err := s.store.GetVoiceState(ctx, guildID, targetID); err == nil { + s.removeFromLiveKit(ctx, guildID, state.ChannelID, targetID) + _ = s.store.DeleteVoiceState(ctx, guildID, targetID) + s.dispatchVoiceLeave(ctx, guildID, state.ChannelID, targetID) + } + s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_ban", map[string]any{ + "user_id": formatSnowflake(targetID), + }) + s.invalidateGuild(guildID) + s.recordAudit(ctx, actor, guildID, "member.ban", "user", &targetID, reason) + s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "user_id": formatSnowflake(targetID), + }) + if s.gateway != nil { + s.gateway.SendToUser(targetID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)}) + } + return s.bansOutput(ctx, guildID) + }) + + huma.Register(api, huma.Operation{ + OperationID: "unbanGuildMember", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/bans/{user_id}", + Summary: "Снять бан", + Tags: []string{"Bans"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + }, + ) (*guildBanListOutput, error) { + actor, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.BanMembers) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if err := s.store.UnbanGuildMember(ctx, guildID, targetID); err != nil { + return nil, humaError(err) + } + s.recordAudit(ctx, actor, guildID, "member.unban", "user", &targetID, "") + return s.bansOutput(ctx, guildID) + }) +} + +// bansOutput отдаёт актуальный список банов: тем же ответом пользуются PUT и +// DELETE, чтобы интерфейс обновлялся без отдельного запроса. +func (s *Server) bansOutput(ctx context.Context, guildID uint64) (*guildBanListOutput, error) { + bans, err := s.store.ListGuildBans(ctx, guildID, 200) + if err != nil { + return nil, humaError(err) + } + output := &guildBanListOutput{} + output.Body.Bans = make([]guildBanPayload, 0, len(bans)) + for _, ban := range bans { + output.Body.Bans = append(output.Body.Bans, banPayload(ban)) + } + return output, nil +} + +func banPayload(ban store.GuildBan) guildBanPayload { + payload := guildBanPayload{ + UserID: formatSnowflake(ban.UserID), + Username: ban.Username, + DisplayName: ban.DisplayName, + ActorName: ban.ActorName, + Reason: ban.Reason, + CreatedAt: ban.CreatedAt.UTC().Format(time.RFC3339), + } + if ban.ActorID != nil { + payload.ActorID = formatSnowflake(*ban.ActorID) + } + return payload +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 7cd7777..78572ce 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -363,8 +363,12 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { Tags: []string{"Guilds"}, Security: security, }, func(ctx context.Context, input *struct { - GuildID string `path:"guild_id"` - Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + GuildID string `path:"guild_id"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + Action string `query:"action" maxLength:"64"` + ActorID string `query:"actor_id"` + TargetID string `query:"target_id"` + BeforeID string `query:"before_id"` }, ) (*auditListOutput, error) { user, _, err := requireUser(ctx) @@ -375,7 +379,25 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { if err != nil { return nil, err } - entries, err := s.store.ListAuditLog(ctx, guildID, input.Limit) + filter := store.AuditFilter{Limit: input.Limit, Action: strings.TrimSpace(input.Action)} + for _, field := range []struct { + raw string + apply func(uint64) + }{ + {input.ActorID, func(value uint64) { filter.ActorID = value }}, + {input.TargetID, func(value uint64) { filter.TargetID = value }}, + {input.BeforeID, func(value uint64) { filter.BeforeID = value }}, + } { + if strings.TrimSpace(field.raw) == "" { + continue + } + parsed, err := parseID("filter", field.raw) + if err != nil { + return nil, err + } + field.apply(parsed) + } + entries, err := s.store.ListAuditLogFiltered(ctx, guildID, filter) if err != nil { return nil, humaError(err) } @@ -786,11 +808,20 @@ func (s *Server) registerMemberRoutes(api huma.API) { return nil, humaError(err) } s.invalidateGuild(guildID) - s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{ + payload := map[string]any{ "guild_id": formatSnowflake(guildID), "user_id": formatSnowflake(targetID), "nickname": nickname, - }) + } + // Тайм-аут показываем в событии: клиенты обновляют состояние без F5. + if timeoutUntil != nil { + if timeoutUntil.After(time.Now().UTC()) { + payload["timeout_until"] = timeoutUntil.UTC().Format(time.RFC3339) + } else { + payload["timeout_until"] = nil + } + } + s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", payload) return newOKOutput(), nil }) @@ -1254,7 +1285,8 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload { if profile.AvatarFileID != nil { payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID) } - if profile.TimeoutUntil != nil { + // Истёкший тайм-аут не показываем: он уже не действует. + if profile.TimeoutUntil != nil && profile.TimeoutUntil.After(time.Now().UTC()) { payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339) } for _, roleID := range profile.RoleIDs { diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go index 0fdc3bf..3f2f5b2 100644 --- a/internal/server/api_invites.go +++ b/internal/server/api_invites.go @@ -181,6 +181,12 @@ func (s *Server) registerInviteRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Бан сервера сильнее приглашения: снять его может только модератор. + if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil { + return nil, humaError(err) + } else if banned { + return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + } isCreator := invite.CreatorID != nil && *invite.CreatorID == user.ID if !isCreator { if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil { @@ -216,6 +222,12 @@ func (s *Server) registerInviteRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Бан сервера сильнее приглашения: снять его может только модератор. + if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil { + return nil, humaError(err) + } else if banned { + return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + } if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) { return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired") } @@ -244,6 +256,12 @@ func (s *Server) registerInviteRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Бан сервера сильнее приглашения: снять его может только модератор. + if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil { + return nil, humaError(err) + } else if banned { + return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + } // Уже участник: повторное принятие не расходует использование. if _, err := s.store.GetGuildMember(ctx, invite.GuildID, user.ID); err == nil { output := &guildIDOutput{} diff --git a/internal/server/moderation_test.go b/internal/server/moderation_test.go new file mode 100644 index 0000000..db15a96 --- /dev/null +++ b/internal/server/moderation_test.go @@ -0,0 +1,324 @@ +package server + +import ( + "net/http" + "testing" + "time" +) + +// Тесты модерации Фазы 4 (AGENT.md 7.17, 7.18): баны, тайм-ауты и фильтры аудита. + +type banListBody struct { + Bans []struct { + UserID string `json:"user_id"` + Username string `json:"username"` + ActorID string `json:"actor_id"` + ActorName string `json:"actor_name"` + Reason string `json:"reason"` + CreatedAt string `json:"created_at"` + } `json:"bans"` +} + +// banFixture поднимает сервер: владелец с сервером и участник, которого банят. +func banFixture(t *testing.T) (srv *Server, owner *http.Cookie, member *http.Cookie, guildID, memberID string) { + t.Helper() + srv, _ = newTestServer(t) + owner = registerAndLogin(t, srv, "ban_owner", "ban-owner@example.com") + member = registerAndLogin(t, srv, "ban_member", "ban-member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, owner) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + guildID = guild.Guild.ID + + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member) + profile := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, me) + memberID = profile.User.ID + + // Участник вступает по приглашению владельца. + invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites", + `{"max_uses":5,"max_age_seconds":600}`, owner) + code := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + } `json:"invite"` + }](t, invite).Invite.Code + accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member) + if accept.Code != http.StatusOK { + t.Fatalf("вступление по приглашению = %d (%s)", accept.Code, accept.Body.String()) + } + return srv, owner, member, guildID, memberID +} + +func TestBanMemberFlow(t *testing.T) { + srv, owner, member, guildID, memberID := banFixture(t) + + // Бан: участник исключается, запись попадает в список. + ban := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID, + `{"reason":"спам"}`, owner) + if ban.Code != http.StatusOK { + t.Fatalf("бан = %d (%s)", ban.Code, ban.Body.String()) + } + bans := decodeResponse[banListBody](t, ban).Bans + if len(bans) != 1 || bans[0].UserID != memberID || bans[0].Reason != "спам" { + t.Fatalf("список банов = %+v", bans) + } + if bans[0].ActorName == "" || bans[0].CreatedAt == "" { + t.Fatalf("в бане нет автора или времени: %+v", bans[0]) + } + + // Исключённый участник больше не видит сервер. + guilds := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/guilds", "", member) + list := decodeResponse[struct { + Guilds []struct { + ID string `json:"id"` + } `json:"guilds"` + }](t, guilds).Guilds + if len(list) != 0 { + t.Fatalf("забаненный видит серверы: %+v", list) + } + + // Повторный вход по приглашению запрещён даже с новым кодом. + invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites", + `{"max_uses":5,"max_age_seconds":600}`, owner) + code := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + } `json:"invite"` + }](t, invite).Invite.Code + again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member) + if again.Code != http.StatusForbidden { + t.Fatalf("вход забаненного = %d, ожидался 403", again.Code) + } + if code := errorCodeOf(t, again); code != "guild.banned" { + t.Fatalf("код ошибки = %q", code) + } + + // Снятие бана возвращает возможность войти. + unban := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/bans/"+memberID, "", owner) + if unban.Code != http.StatusOK { + t.Fatalf("снятие бана = %d (%s)", unban.Code, unban.Body.String()) + } + if bans := decodeResponse[banListBody](t, unban).Bans; len(bans) != 0 { + t.Fatalf("после снятия бана список = %+v", bans) + } + rejoin := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member) + if rejoin.Code != http.StatusOK { + t.Fatalf("повторный вход после снятия бана = %d (%s)", rejoin.Code, rejoin.Body.String()) + } +} + +func TestBanRequiresPermissionAndHierarchy(t *testing.T) { + srv, owner, member, guildID, memberID := banFixture(t) + + // Участник без BAN_MEMBERS не может банить и не видит список. + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/bans", "", member) + if list.Code != http.StatusForbidden { + t.Fatalf("список банов без прав = %d", list.Code) + } + denied := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID, + `{"reason":"сам себя"}`, member) + if denied.Code != http.StatusForbidden { + t.Fatalf("бан без прав = %d", denied.Code) + } + + // Владельца забанить нельзя даже администратору сервера. + self := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) + ownerID := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, self).User.ID + ownerBan := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+ownerID, + `{}`, owner) + if ownerBan.Code != http.StatusForbidden { + t.Fatalf("бан владельца = %d, ожидался 403", ownerBan.Code) + } +} + +func TestMemberTimeoutBlocksSending(t *testing.T) { + srv, owner, member, guildID, memberID := banFixture(t) + + channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner) + channelID := decodeResponse[struct { + Channels []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"channels"` + }](t, channels).Channels[0].ID + + // До тайм-аута сообщение отправляется. + sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", + `{"content":"до тайм-аута"}`, member) + if sent.Code != http.StatusOK { + t.Fatalf("сообщение до тайм-аута = %d (%s)", sent.Code, sent.Body.String()) + } + + // Тайм-аут на час: отправка запрещена, состояние видно в участниках. + until := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) + timeout := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+memberID, + `{"timeout_until":"`+until+`"}`, owner) + if timeout.Code != http.StatusOK { + t.Fatalf("тайм-аут = %d (%s)", timeout.Code, timeout.Body.String()) + } + blocked := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", + `{"content":"во время тайм-аута"}`, member) + if blocked.Code != http.StatusForbidden { + t.Fatalf("сообщение в тайм-ауте = %d, ожидался 403", blocked.Code) + } + if code := errorCodeOf(t, blocked); code != "perm.denied" && code != "member.timed_out" { + t.Fatalf("код ошибки тайм-аута = %q", code) + } + + members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner) + found := false + for _, item := range decodeResponse[struct { + Members []struct { + UserID string `json:"user_id"` + TimeoutUntil string `json:"timeout_until"` + } `json:"members"` + }](t, members).Members { + if item.UserID == memberID { + found = true + if item.TimeoutUntil == "" { + t.Fatal("тайм-аут не виден в списке участников") + } + } + } + if !found { + t.Fatal("участник не найден в списке") + } + + // Снятие тайм-аута возвращает отправку. + cleared := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+memberID, + `{"timeout_until":"2000-01-01T00:00:00Z"}`, owner) + if cleared.Code != http.StatusOK { + t.Fatalf("снятие тайм-аута = %d (%s)", cleared.Code, cleared.Body.String()) + } + restored := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", + `{"content":"после тайм-аута"}`, member) + if restored.Code != http.StatusOK { + t.Fatalf("сообщение после тайм-аута = %d (%s)", restored.Code, restored.Body.String()) + } +} + +func TestTimeoutCannotBeSetOnSelf(t *testing.T) { + srv, owner, _, guildID, _ := banFixture(t) // участник не нужен: проверяем себя + self := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) + ownerID := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, self).User.ID + + until := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) + rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+ownerID, + `{"timeout_until":"`+until+`"}`, owner) + if rec.Code != http.StatusForbidden { + t.Fatalf("тайм-аут на себя = %d, ожидался 403", rec.Code) + } +} + +func TestAuditLogFilters(t *testing.T) { + srv, owner, _, guildID, memberID := banFixture(t) + + // Два разных действия: бан участника и создание комнаты. + doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID, `{"reason":"спам"}`, owner) + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels", + `{"name":"комната","type":"text"}`, owner) + + all := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log?limit=50", "", owner) + if code := all.Code; code != http.StatusOK { + t.Fatalf("журнал = %d (%s)", code, all.Body.String()) + } + entries := decodeResponse[struct { + Entries []struct { + ID string `json:"id"` + Action string `json:"action"` + Target string `json:"target,omitempty"` + TargetID string `json:"target_id,omitempty"` + } `json:"entries"` + }](t, all).Entries + if len(entries) == 0 { + t.Fatal("журнал пуст") + } + + // Фильтр по действию. + bansOnly := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log?action=member.ban", "", owner) + banEntries := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + } `json:"entries"` + }](t, bansOnly).Entries + if len(banEntries) != 1 || banEntries[0].Action != "member.ban" { + t.Fatalf("фильтр по действию = %+v", banEntries) + } + + // Фильтр по цели: с участником связаны и бан, и принятие приглашения, + // поэтому проверяем, что все записи про него и есть бан среди них. + byTarget := doJSON(t, srv, http.MethodGet, + "/api/v1/guilds/"+guildID+"/audit-log?target_id="+memberID, "", owner) + targetEntries := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + TargetID string `json:"target_id"` + } `json:"entries"` + }](t, byTarget).Entries + if len(targetEntries) == 0 { + t.Fatal("фильтр по цели ничего не вернул") + } + sawBan := false + for _, entry := range targetEntries { + if entry.TargetID != memberID { + t.Fatalf("в выборке чужая цель: %+v", entry) + } + sawBan = sawBan || entry.Action == "member.ban" + } + if !sawBan { + t.Fatalf("в выборке нет бана: %+v", targetEntries) + } + + // Комбинация фильтров: действие и цель одновременно. + combined := doJSON(t, srv, http.MethodGet, + "/api/v1/guilds/"+guildID+"/audit-log?action=member.ban&target_id="+memberID, "", owner) + combinedEntries := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + } `json:"entries"` + }](t, combined).Entries + if len(combinedEntries) != 1 || combinedEntries[0].Action != "member.ban" { + t.Fatalf("комбинированный фильтр = %+v", combinedEntries) + } + + // Постранично: before_id отдаёт только более старые записи. + newest := entries[0].ID + older := doJSON(t, srv, http.MethodGet, + "/api/v1/guilds/"+guildID+"/audit-log?before_id="+newest, "", owner) + olderEntries := decodeResponse[struct { + Entries []struct { + ID string `json:"id"` + } `json:"entries"` + }](t, older).Entries + for _, entry := range olderEntries { + if entry.ID >= newest { + t.Fatalf("before_id вернул свежую запись: %s ≥ %s", entry.ID, newest) + } + } + if len(olderEntries) != len(entries)-1 { + t.Fatalf("страница = %d записей, ожидалось %d", len(olderEntries), len(entries)-1) + } + + // Чужой сервер и отсутствие прав: журнал недоступен. + stranger := registerAndLogin(t, srv, "audit_stranger", "audit-stranger@example.com") + denied := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log", "", stranger) + if denied.Code != http.StatusNotFound && denied.Code != http.StatusForbidden { + t.Fatalf("журнал чужого сервера = %d", denied.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5bf9dec..85b75f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -139,6 +139,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerUserRoutes(s.api) s.registerGuildRoutes(s.api) s.registerInstanceRoutes(s.api) + s.registerBanRoutes(s.api) s.registerMetricsRoutes(s.api) s.registerMessageRoutes(s.api) s.registerInviteRoutes(s.api) diff --git a/internal/store/bans.go b/internal/store/bans.go new file mode 100644 index 0000000..db47055 --- /dev/null +++ b/internal/store/bans.go @@ -0,0 +1,152 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +// Баны сервера (AGENT.md 7.17): бан переживает исключение участника и +// запрещает повторный вход по приглашению до снятия. + +// GuildBan — запись о бане. +type GuildBan struct { + UserID uint64 + Username string + DisplayName string + ActorID *uint64 + ActorName string + Reason string + CreatedAt time.Time +} + +// BanGuildMember добавляет бан: повторный бан обновляет причину и автора. +func (s *Store) BanGuildMember(ctx context.Context, guildID, userID, actorID uint64, reason string) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO guild_bans (guild_id, user_id, actor_id, reason, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (guild_id, user_id) DO UPDATE SET + actor_id = excluded.actor_id, + reason = excluded.reason, + created_at = excluded.created_at`, + int64(guildID), int64(userID), int64(actorID), reason, s.Now()) + return err +} + +// UnbanGuildMember снимает бан: отсутствие бана — не ошибка (идемпотентность). +func (s *Store) UnbanGuildMember(ctx context.Context, guildID, userID uint64) error { + _, err := s.writer.ExecContext(ctx, + `DELETE FROM guild_bans WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)) + return err +} + +// IsGuildBanned сообщает, забанен ли пользователь на сервере. +func (s *Store) IsGuildBanned(ctx context.Context, guildID, userID uint64) (bool, error) { + var exists int + err := s.reader.QueryRowContext(ctx, + `SELECT 1 FROM guild_bans WHERE guild_id = ? AND user_id = ?`, + int64(guildID), int64(userID)).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// ListGuildBans перечисляет баны сервера: сначала свежие. +func (s *Store) ListGuildBans(ctx context.Context, guildID uint64, limit int) ([]GuildBan, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT b.user_id, u.username, COALESCE(u.display_name, ''), b.actor_id, + COALESCE(a.username, ''), b.reason, b.created_at + FROM guild_bans b + JOIN users u ON u.id = b.user_id + LEFT JOIN users a ON a.id = b.actor_id + WHERE b.guild_id = ? + ORDER BY b.created_at DESC, b.user_id DESC + LIMIT ?`, int64(guildID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + bans := make([]GuildBan, 0, limit) + for rows.Next() { + var ( + ban GuildBan + userID int64 + actorID sql.NullInt64 + createdAt string + ) + if err := rows.Scan(&userID, &ban.Username, &ban.DisplayName, &actorID, + &ban.ActorName, &ban.Reason, &createdAt); err != nil { + return nil, err + } + ban.UserID = uint64(userID) + if actorID.Valid { + value := uint64(actorID.Int64) + ban.ActorID = &value + } + ban.CreatedAt = parseTimestamp(createdAt) + bans = append(bans, ban) + } + return bans, rows.Err() +} + +// AuditFilter — фильтры журнала аудита (AGENT.md 7.18). +type AuditFilter struct { + Action string + ActorID uint64 + TargetID uint64 + BeforeID uint64 + Limit int +} + +// ListAuditLog отдаёт журнал сервера с фильтрами по действию, автору и цели. +func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) { + return s.ListAuditLogFiltered(ctx, guildID, AuditFilter{Limit: limit}) +} + +// ListAuditLogFiltered отдаёт журнал сервера с учётом фильтров: пустые +// значения означают «без ограничения», BeforeID — постраничный просмотр. +func (s *Store) ListAuditLogFiltered(ctx context.Context, guildID uint64, filter AuditFilter) ([]AuditEntry, error) { + limit := filter.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + query := strings.Builder{} + query.WriteString(` + 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 = ?`) + args := []any{int64(guildID)} + if filter.Action != "" { + query.WriteString(" AND action = ?") + args = append(args, filter.Action) + } + if filter.ActorID != 0 { + query.WriteString(" AND actor_id = ?") + args = append(args, int64(filter.ActorID)) + } + if filter.TargetID != 0 { + query.WriteString(" AND target_id = ?") + args = append(args, int64(filter.TargetID)) + } + if filter.BeforeID != 0 { + query.WriteString(" AND id < ?") + args = append(args, int64(filter.BeforeID)) + } + query.WriteString(" ORDER BY id DESC LIMIT ?") + args = append(args, limit) + + rows, err := s.reader.QueryContext(ctx, query.String(), args...) + if err != nil { + return nil, err + } + return scanAuditEntries(rows, limit) +} diff --git a/internal/store/instance.go b/internal/store/instance.go index 8223f06..9a2db40 100644 --- a/internal/store/instance.go +++ b/internal/store/instance.go @@ -117,19 +117,6 @@ func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error { 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 - } - return scanAuditEntries(rows, limit) -} - func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) { defer rows.Close()