189 lines
6.1 KiB
Go
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
|
||
|
|
}
|