feat(moderation): баны сервера, тайм-ауты и фильтры журнала аудита
Фаза 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`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
This commit is contained in:
@@ -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;
|
||||||
@@ -21,6 +21,9 @@ type guildKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type channelKey struct {
|
type channelKey struct {
|
||||||
|
// guildID нужен, чтобы сброс сервера (роли, тайм-аут, оверрайды) сбрасывал
|
||||||
|
// и кэш его комнат: иначе участник с тайм-аутом продолжал писать.
|
||||||
|
guildID uint64
|
||||||
channelID uint64
|
channelID uint64
|
||||||
userID uint64
|
userID uint64
|
||||||
}
|
}
|
||||||
@@ -69,10 +72,10 @@ func (c *Cache) PutGuild(guildID, userID uint64, value Resolved) {
|
|||||||
c.evictLocked()
|
c.evictLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) {
|
func (c *Cache) GetChannel(guildID, channelID, userID uint64) (Resolved, bool) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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 {
|
if !ok {
|
||||||
return Resolved{}, false
|
return Resolved{}, false
|
||||||
}
|
}
|
||||||
@@ -80,10 +83,10 @@ func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) {
|
|||||||
return element.Value.(*cacheEntry).value, true
|
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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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 {
|
if element, ok := c.channels[key]; ok {
|
||||||
element.Value.(*cacheEntry).value = value
|
element.Value.(*cacheEntry).value = value
|
||||||
c.order.MoveToFront(element)
|
c.order.MoveToFront(element)
|
||||||
@@ -94,7 +97,8 @@ func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) {
|
|||||||
c.evictLocked()
|
c.evictLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateGuild сбрасывает права всех пользователей сервера.
|
// InvalidateGuild сбрасывает права всех пользователей сервера, включая кэш
|
||||||
|
// его комнат: роли, тайм-аут и оверрайды влияют и на права в комнатах.
|
||||||
func (c *Cache) InvalidateGuild(guildID uint64) {
|
func (c *Cache) InvalidateGuild(guildID uint64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -104,6 +108,12 @@ func (c *Cache) InvalidateGuild(guildID uint64) {
|
|||||||
delete(c.items, key)
|
delete(c.items, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key, element := range c.channels {
|
||||||
|
if key.guildID == guildID {
|
||||||
|
c.order.Remove(element)
|
||||||
|
delete(c.channels, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateChannel сбрасывает кэш комнаты.
|
// InvalidateChannel сбрасывает кэш комнаты.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
// Channel вычисляет права пользователя в конкретной комнате: базовые права
|
// Channel вычисляет права пользователя в конкретной комнате: базовые права
|
||||||
// сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2).
|
// сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2).
|
||||||
func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) (Resolved, error) {
|
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
|
cached.IsInstanceAdmin = instanceAdmin
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,11 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin
|
|||||||
resolved.Channel = resolved.Guild
|
resolved.Channel = resolved.Guild
|
||||||
if !resolved.IsMember && !resolved.IsInstanceAdmin {
|
if !resolved.IsMember && !resolved.IsInstanceAdmin {
|
||||||
// Не участник не видит ничего, даже если есть персональный оверрайд.
|
// Не участник не видит ничего, даже если есть персональный оверрайд.
|
||||||
c.cache.PutChannel(channelID, userID, resolved)
|
c.cache.PutChannel(guildID, channelID, userID, resolved)
|
||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
|
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
|
||||||
c.cache.PutChannel(channelID, userID, resolved)
|
c.cache.PutChannel(guildID, channelID, userID, resolved)
|
||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +78,6 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolved.Channel = (computed &^ userDeny) | userAllow
|
resolved.Channel = (computed &^ userDeny) | userAllow
|
||||||
c.cache.PutChannel(channelID, userID, resolved)
|
c.cache.PutChannel(guildID, channelID, userID, resolved)
|
||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -363,8 +363,12 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
Tags: []string{"Guilds"},
|
Tags: []string{"Guilds"},
|
||||||
Security: security,
|
Security: security,
|
||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
GuildID string `path:"guild_id"`
|
GuildID string `path:"guild_id"`
|
||||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
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) {
|
) (*auditListOutput, error) {
|
||||||
user, _, err := requireUser(ctx)
|
user, _, err := requireUser(ctx)
|
||||||
@@ -375,7 +379,25 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
@@ -786,11 +808,20 @@ func (s *Server) registerMemberRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{
|
payload := map[string]any{
|
||||||
"guild_id": formatSnowflake(guildID),
|
"guild_id": formatSnowflake(guildID),
|
||||||
"user_id": formatSnowflake(targetID),
|
"user_id": formatSnowflake(targetID),
|
||||||
"nickname": nickname,
|
"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
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1254,7 +1285,8 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
|
|||||||
if profile.AvatarFileID != nil {
|
if profile.AvatarFileID != nil {
|
||||||
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
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)
|
payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
for _, roleID := range profile.RoleIDs {
|
for _, roleID := range profile.RoleIDs {
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func (s *Server) registerInviteRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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
|
isCreator := invite.CreatorID != nil && *invite.CreatorID == user.ID
|
||||||
if !isCreator {
|
if !isCreator {
|
||||||
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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()) {
|
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
|
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
|
||||||
}
|
}
|
||||||
@@ -244,6 +256,12 @@ func (s *Server) registerInviteRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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 {
|
if _, err := s.store.GetGuildMember(ctx, invite.GuildID, user.ID); err == nil {
|
||||||
output := &guildIDOutput{}
|
output := &guildIDOutput{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerUserRoutes(s.api)
|
s.registerUserRoutes(s.api)
|
||||||
s.registerGuildRoutes(s.api)
|
s.registerGuildRoutes(s.api)
|
||||||
s.registerInstanceRoutes(s.api)
|
s.registerInstanceRoutes(s.api)
|
||||||
|
s.registerBanRoutes(s.api)
|
||||||
s.registerMetricsRoutes(s.api)
|
s.registerMetricsRoutes(s.api)
|
||||||
s.registerMessageRoutes(s.api)
|
s.registerMessageRoutes(s.api)
|
||||||
s.registerInviteRoutes(s.api)
|
s.registerInviteRoutes(s.api)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -117,19 +117,6 @@ func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error {
|
|||||||
return err
|
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) {
|
func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user