Files
glchat/internal/server/api_bans.go
T
grendervill 4fb3509128 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`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
  блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
2026-09-20 22:38:48 +03:00

189 lines
6.1 KiB
Go

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
}