73dd51b121
§11.5 требует, чтобы каждое мутирующее действие писалось в журнал сервера
с `actor.instance_admin=true`, но у PATCH /guilds/{id},
PATCH /guilds/{id}/members/{user_id} и у ручек комнат вызова `recordAudit`
не было: переименование сервера, смена ника, тайм-аут и создание, правка и
удаление комнаты не оставляли следа ни у владельца, ни у инстанс-админа.
- guild.update, member.update, member.timeout (в причине — срок тайм-аута),
channel.create, channel.update, channel.delete;
- Go-тест TestAuditCoversGuildMemberAndChannelChanges проверяет записи на
чужом сервере и флаг actor_instance_admin, а также что действие владельца
этим флагом не помечается;
- e2e 19b в instance-admin.spec.ts включён вместо test.fixme с диагностикой.
Проверки: go test -tags sqlite_fts5 -race -count=1 ./internal/... — ok;
make go-lint — 0 issues; живьём на стенде тест 19b проходит.
1981 lines
71 KiB
Go
1981 lines
71 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/danielgtaylor/huma/v2"
|
||
|
||
"glchat/internal/bootstrap"
|
||
"glchat/internal/permissions"
|
||
"glchat/internal/store"
|
||
)
|
||
|
||
type channelPayload struct {
|
||
ID string `json:"id"`
|
||
GuildID string `json:"guild_id,omitempty"`
|
||
Type string `json:"type"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Position int `json:"position"`
|
||
ParentID string `json:"parent_id,omitempty"`
|
||
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||
UserLimit int `json:"user_limit"`
|
||
CanView bool `json:"can_view"`
|
||
CanSend bool `json:"can_send"`
|
||
CanConnect bool `json:"can_connect"`
|
||
// BackgroundFileID — фон комнаты (AGENT.md 7.5, 7.7).
|
||
BackgroundFileID string `json:"background_file_id,omitempty"`
|
||
// PermissionOverwrites — права комнаты поверх серверных (AGENT.md 6.2):
|
||
// именно ими закрывают комнату для @everyone и открывают её ролям.
|
||
PermissionOverwrites []channelOverwritePayload `json:"permission_overwrites"`
|
||
}
|
||
|
||
type channelOverwritePayload struct {
|
||
// TargetType — role или user.
|
||
TargetType string `json:"target_type"`
|
||
TargetID string `json:"target_id"`
|
||
Allow string `json:"allow"`
|
||
Deny string `json:"deny"`
|
||
}
|
||
|
||
func channelOverwritePayloads(overrides []store.ChannelOverride) []channelOverwritePayload {
|
||
payloads := make([]channelOverwritePayload, 0, len(overrides))
|
||
for _, override := range overrides {
|
||
payloads = append(payloads, channelOverwritePayload{
|
||
TargetType: override.TargetType,
|
||
TargetID: formatSnowflake(override.TargetID),
|
||
Allow: strings.Join(permissions.Names(permissions.Permission(override.Allow)), "|"),
|
||
Deny: strings.Join(permissions.Names(permissions.Permission(override.Deny)), "|"),
|
||
})
|
||
}
|
||
return payloads
|
||
}
|
||
|
||
type rolePayload struct {
|
||
ID string `json:"id"`
|
||
// GuildID нужен событиям ROLE_CREATE/ROLE_UPDATE: в REST он избыточен
|
||
// (сервер и так в пути), а в кадре шлюза без него роль некуда отнести.
|
||
GuildID string `json:"guild_id,omitempty"`
|
||
Name string `json:"name"`
|
||
Color int64 `json:"color"`
|
||
Position int `json:"position"`
|
||
Permissions string `json:"permissions"`
|
||
IsDefault bool `json:"is_default"`
|
||
Hoist bool `json:"hoist"`
|
||
Mentionable bool `json:"mentionable"`
|
||
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
|
||
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
|
||
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
|
||
NickColor *int64 `json:"nick_color,omitempty"`
|
||
NickEffect string `json:"nick_effect,omitempty"`
|
||
}
|
||
|
||
type memberPayload struct {
|
||
UserID string `json:"user_id"`
|
||
Username string `json:"username"`
|
||
DisplayName string `json:"display_name"`
|
||
Nickname string `json:"nickname,omitempty"`
|
||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||
Status string `json:"status"`
|
||
CustomStatus string `json:"custom_status"`
|
||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||
JoinedAt string `json:"joined_at"`
|
||
TimeoutUntil string `json:"timeout_until,omitempty"`
|
||
RoleIDs []string `json:"role_ids"`
|
||
// Системные бейджи (AGENT.md 7.2): назначаются только системой.
|
||
Badges []string `json:"badges"`
|
||
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
|
||
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||
}
|
||
|
||
type guildDetailPayload struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
OwnerID string `json:"owner_id"`
|
||
IconFileID string `json:"icon_file_id,omitempty"`
|
||
BannerFileID string `json:"banner_file_id,omitempty"`
|
||
SplashFileID string `json:"splash_file_id,omitempty"`
|
||
AccentColor int64 `json:"accent_color,omitempty"`
|
||
IsMain bool `json:"is_main"`
|
||
MemberCount int `json:"member_count"`
|
||
Roles []rolePayload `json:"roles"`
|
||
MyRoleIDs []string `json:"my_role_ids"`
|
||
MyPermissions []string `json:"my_permissions"`
|
||
Channels []channelPayload `json:"channels,omitempty"`
|
||
}
|
||
|
||
// channelEventPayload собирает тело комнаты для события шлюза: права в нём
|
||
// не вычислены (они персональные), поэтому признаки can_* не выставляются —
|
||
// клиент по этому событию перечитывает список комнат, а не прячет комнату по
|
||
// нулевому can_view.
|
||
func channelEventPayload(channel store.Channel) channelPayload {
|
||
return channelFromStore(channel, nil, nil)
|
||
}
|
||
|
||
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
|
||
payload := channelPayload{
|
||
ID: formatSnowflake(channel.ID),
|
||
Type: string(channel.Type),
|
||
Name: channel.Name,
|
||
Description: channel.Description,
|
||
Position: channel.Position,
|
||
SlowmodeSeconds: channel.SlowmodeSeconds,
|
||
UserLimit: channel.UserLimit,
|
||
}
|
||
if channel.GuildID != nil {
|
||
payload.GuildID = formatSnowflake(*channel.GuildID)
|
||
}
|
||
if channel.ParentID != nil {
|
||
payload.ParentID = formatSnowflake(*channel.ParentID)
|
||
}
|
||
if channel.BackgroundFileID != nil {
|
||
payload.BackgroundFileID = formatSnowflake(*channel.BackgroundFileID)
|
||
}
|
||
if resolved != nil {
|
||
payload.CanView = resolved.CanViewChannel()
|
||
payload.CanSend = resolved.Can(permissions.SendMessages)
|
||
payload.CanConnect = resolved.Can(permissions.ConnectVoice)
|
||
}
|
||
payload.PermissionOverwrites = channelOverwritePayloads(overrides)
|
||
return payload
|
||
}
|
||
|
||
func roleFromStore(role store.Role) rolePayload {
|
||
payload := rolePayload{
|
||
ID: formatSnowflake(role.ID),
|
||
GuildID: formatSnowflake(role.GuildID),
|
||
Name: role.Name,
|
||
Color: role.Color,
|
||
Position: role.Position,
|
||
Permissions: permissions.Permission(role.Permissions).String(),
|
||
IsDefault: role.IsDefault,
|
||
Hoist: role.Hoist,
|
||
Mentionable: role.Mentionable,
|
||
NickColor: role.NickColor,
|
||
}
|
||
if role.CosmeticFrameID != nil {
|
||
payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID)
|
||
}
|
||
if role.CosmeticBadgeID != nil {
|
||
payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID)
|
||
}
|
||
if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) {
|
||
payload.NickEffect = role.NickEffect
|
||
}
|
||
return payload
|
||
}
|
||
|
||
type guildOutput struct {
|
||
Body struct {
|
||
Guild guildDetailPayload `json:"guild"`
|
||
}
|
||
}
|
||
|
||
type channelOutput struct {
|
||
Body struct {
|
||
Channel channelPayload `json:"channel"`
|
||
}
|
||
}
|
||
|
||
type channelListOutput struct {
|
||
Body struct {
|
||
Channels []channelPayload `json:"channels"`
|
||
}
|
||
}
|
||
|
||
type memberListOutput struct {
|
||
Body struct {
|
||
Members []memberPayload `json:"members"`
|
||
}
|
||
}
|
||
|
||
type roleOutput struct {
|
||
Body struct {
|
||
Role rolePayload `json:"role"`
|
||
}
|
||
}
|
||
|
||
type roleListOutput struct {
|
||
Body struct {
|
||
Roles []rolePayload `json:"roles"`
|
||
}
|
||
}
|
||
|
||
// registerGuildRoutes описывает ручки серверов, комнат, участников и ролей.
|
||
func (s *Server) registerGuildRoutes(api huma.API) {
|
||
s.registerGuildCoreRoutes(api)
|
||
s.registerChannelRoutes(api)
|
||
s.registerMemberRoutes(api)
|
||
s.registerRoleRoutes(api)
|
||
}
|
||
|
||
func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "createGuild",
|
||
Method: http.MethodPost,
|
||
Path: "/guilds",
|
||
Summary: "Создать сервер",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
Body struct {
|
||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||
// TemplateID — встроенный шаблон структуры (AGENT.md 7.3);
|
||
// пусто — «Пустой».
|
||
TemplateID string `json:"template_id,omitempty"`
|
||
}
|
||
},
|
||
) (*guildOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := strings.TrimSpace(input.Body.Name)
|
||
if name == "" {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||
}
|
||
guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.guildOutput(ctx, user, *guild)
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "listGuildTemplates",
|
||
Method: http.MethodGet,
|
||
Path: "/guild-templates",
|
||
Summary: "Встроенные шаблоны серверов",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) {
|
||
if _, _, err := requireUser(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
output := &guildTemplateListOutput{}
|
||
output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates))
|
||
for _, template := range guildTemplates {
|
||
output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template))
|
||
}
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "getGuild",
|
||
Method: http.MethodGet,
|
||
Path: "/guilds/{guild_id}",
|
||
Summary: "Сервер: роли и права",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*guildOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
return s.guildOutput(ctx, user, *guild)
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "updateGuild",
|
||
Method: http.MethodPatch,
|
||
Path: "/guilds/{guild_id}",
|
||
Summary: "Изменить сервер",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
Body struct {
|
||
Name *string `json:"name,omitempty" maxLength:"64"`
|
||
Description *string `json:"description,omitempty" maxLength:"500"`
|
||
// Публичность сервера: попадает в каталог и открывает вход без
|
||
// приглашения (AGENT.md 7.20).
|
||
Public *bool `json:"public,omitempty"`
|
||
// Акцентный цвет сервера (0 — цвет темы по умолчанию).
|
||
AccentColor *int64 `json:"accent_color,omitempty" minimum:"0" maximum:"16777215"`
|
||
}
|
||
},
|
||
) (*guildOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
params := store.UpdateGuildParams{
|
||
Description: input.Body.Description,
|
||
AccentColor: input.Body.AccentColor,
|
||
}
|
||
if input.Body.Public != nil {
|
||
// Главный сервер публичным не делаем: он и так точка входа.
|
||
if *input.Body.Public {
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if guild.IsMain {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published")
|
||
}
|
||
}
|
||
params.IsDiscoverable = input.Body.Public
|
||
}
|
||
if input.Body.Name != nil {
|
||
trimmed := strings.TrimSpace(*input.Body.Name)
|
||
if trimmed == "" {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||
}
|
||
params.Name = &trimmed
|
||
}
|
||
guild, err := s.store.UpdateGuild(ctx, guildID, params)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.dispatchGuildUpdate(*guild)
|
||
// Правка сервера попадает в журнал: раньше её не было ни у владельца,
|
||
// ни у инстанс-админа (AGENT.md 11.5).
|
||
s.recordAudit(ctx, user, guildID, "guild.update", "guild", &guildID, "")
|
||
return s.guildOutput(ctx, user, *guild)
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "deleteGuild",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}",
|
||
Summary: "Удалить сервер (владелец или админ инстанса)",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, session, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !resolved.IsOwner && !user.IsInstanceAdmin {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the owner can delete the guild")
|
||
}
|
||
// Удаление сервера требует свежего подтверждения личности (AGENT.md 9.3).
|
||
// Тело у DELETE не спрашиваем: клиент сначала вызывает /auth/step-up.
|
||
if err := s.auth.RequireStepUp(ctx, user, session, "", ""); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if err := s.deleteGuild(ctx, user, guildID); err != nil {
|
||
return nil, err
|
||
}
|
||
return newOKOutput(), nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "joinGuild",
|
||
Method: http.MethodPost,
|
||
Path: "/guilds/{guild_id}/join",
|
||
Summary: "Присоединиться к открытому серверу",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, err := parseID("guild_id", input.GuildID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.joinGuild(ctx, user, guildID); err != nil {
|
||
return nil, err
|
||
}
|
||
return newOKOutput(), nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "leaveGuild",
|
||
Method: http.MethodPost,
|
||
Path: "/guilds/{guild_id}/leave",
|
||
Summary: "Покинуть сервер",
|
||
Tags: []string{"Guilds"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resolved.IsOwner {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "guild.owner_cannot_leave", "owner cannot leave the guild")
|
||
}
|
||
if err := s.store.RemoveGuildMember(ctx, guildID, user.ID); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
if s.gateway != nil {
|
||
s.gateway.SendToUser(user.ID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)})
|
||
}
|
||
return newOKOutput(), nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "listAuditLog",
|
||
Method: http.MethodGet,
|
||
Path: "/guilds/{guild_id}/audit-log",
|
||
Summary: "Журнал действий сервера",
|
||
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"`
|
||
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)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewAuditLog)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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)
|
||
}
|
||
output := &auditListOutput{}
|
||
output.Body.Entries = auditPayloads(entries)
|
||
return output, nil
|
||
})
|
||
}
|
||
|
||
func (s *Server) registerChannelRoutes(api huma.API) {
|
||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "listGuildChannels",
|
||
Method: http.MethodGet,
|
||
Path: "/guilds/{guild_id}/channels",
|
||
Summary: "Комнаты сервера, видимые пользователю",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*channelListOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channels, err := s.visibleChannels(ctx, guildID, user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
output := &channelListOutput{}
|
||
output.Body.Channels = channels
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "createChannel",
|
||
Method: http.MethodPost,
|
||
Path: "/guilds/{guild_id}/channels",
|
||
Summary: "Создать комнату",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
Body struct {
|
||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||
Type string `json:"type,omitempty" enum:"text,voice,category"`
|
||
ParentID string `json:"parent_id,omitempty"`
|
||
SlowmodeSeconds int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"`
|
||
UserLimit int `json:"user_limit,omitempty" minimum:"0" maximum:"99"`
|
||
}
|
||
},
|
||
) (*channelOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelType := store.ChannelType(input.Body.Type)
|
||
if channelType == "" {
|
||
channelType = store.ChannelText
|
||
}
|
||
switch channelType {
|
||
// Личные комнаты (dm) создаются не через API сервера (AGENT.md 6.3).
|
||
case store.ChannelText, store.ChannelVoice, store.ChannelCategory:
|
||
case store.ChannelDM:
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "dm channels are created by the messaging API")
|
||
default:
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown channel type")
|
||
}
|
||
name := strings.TrimSpace(input.Body.Name)
|
||
if name == "" {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "channel name must not be empty")
|
||
}
|
||
params := store.CreateChannelParams{
|
||
GuildID: &guildID,
|
||
Type: channelType,
|
||
Name: name,
|
||
SlowmodeSeconds: input.Body.SlowmodeSeconds,
|
||
UserLimit: input.Body.UserLimit,
|
||
}
|
||
if input.Body.ParentID != "" {
|
||
parentID, err := parseID("parent_id", input.Body.ParentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
params.ParentID = &parentID
|
||
}
|
||
channel, err := s.store.CreateChannel(ctx, params)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Системная запись о создании комнаты (AGENT.md 7.6).
|
||
s.postSystemMessage(ctx, channel.ID, "channel_created", map[string]any{
|
||
"name": channel.Name,
|
||
"type": string(channel.Type),
|
||
})
|
||
// Создание комнаты (в том числе категории) — в журнале сервера.
|
||
s.recordAudit(ctx, user, guildID, "channel.create", "channel", &channel.ID, "")
|
||
s.invalidateGuild(guildID)
|
||
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
|
||
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
|
||
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelEventPayload(*channel))
|
||
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
})
|
||
// Права на новую комнату считаем для автора запроса.
|
||
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
output := &channelOutput{}
|
||
output.Body.Channel = channelFromStore(*channel, &resolved, nil)
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "updateChannel",
|
||
Method: http.MethodPatch,
|
||
Path: "/guilds/{guild_id}/channels/{channel_id}",
|
||
Summary: "Изменить комнату",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
ChannelID string `path:"channel_id"`
|
||
Body struct {
|
||
Name *string `json:"name,omitempty" maxLength:"64"`
|
||
Description *string `json:"description,omitempty" maxLength:"500"`
|
||
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
|
||
SlowmodeSeconds *int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"`
|
||
UserLimit *int `json:"user_limit,omitempty" minimum:"0" maximum:"99"`
|
||
}
|
||
},
|
||
) (*channelOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelID, err := parseID("channel_id", input.ChannelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
params := store.UpdateChannelParams{
|
||
Name: input.Body.Name,
|
||
Description: input.Body.Description,
|
||
Position: input.Body.Position,
|
||
SlowmodeSeconds: input.Body.SlowmodeSeconds,
|
||
UserLimit: input.Body.UserLimit,
|
||
}
|
||
channel, err := s.store.UpdateChannel(ctx, channelID, params)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "channel.update", "channel", &channelID, "")
|
||
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
|
||
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
})
|
||
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
output := &channelOutput{}
|
||
output.Body.Channel = channelFromStore(*channel, &resolved, nil)
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "setChannelOverwrite",
|
||
Method: http.MethodPut,
|
||
Path: "/guilds/{guild_id}/channels/{channel_id}/overwrites/{target_type}/{target_id}",
|
||
Summary: "Задать права комнаты для роли или участника",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
ChannelID string `path:"channel_id"`
|
||
TargetType string `path:"target_type" enum:"role,user"`
|
||
TargetID string `path:"target_id"`
|
||
Body struct {
|
||
// Allow и Deny — имена прав через `|`, как в ролях (AGENT.md 6.2, 8.1).
|
||
Allow string `json:"allow,omitempty"`
|
||
Deny string `json:"deny,omitempty"`
|
||
// Смена прав комнаты — чувствительная операция (AGENT.md 9.3).
|
||
StepUpPassword string `json:"step_up_password,omitempty"`
|
||
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||
}
|
||
},
|
||
) (*channelOutput, error) {
|
||
user, session, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelID, err := parseID("channel_id", input.ChannelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
targetID, err := parseID("target_id", input.TargetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireOverwriteTarget(ctx, guildID, input.TargetType, targetID); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
allow, err := permissions.Parse(input.Body.Allow)
|
||
if err != nil {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
|
||
}
|
||
deny, err := permissions.Parse(input.Body.Deny)
|
||
if err != nil {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
|
||
}
|
||
if err := s.store.SetChannelOverride(ctx, store.ChannelOverride{
|
||
ChannelID: channelID,
|
||
TargetType: input.TargetType,
|
||
TargetID: targetID,
|
||
Allow: uint64(allow),
|
||
Deny: uint64(deny),
|
||
}); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.afterOverwriteChange(ctx, user, guildID, channelID, "channel.overwrite_set",
|
||
input.TargetType, targetID)
|
||
channel, err := s.store.GetChannel(ctx, channelID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
overrides, err := s.store.ListChannelOverrides(ctx, channelID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
output := &channelOutput{}
|
||
output.Body.Channel = channelFromStore(*channel, &resolved, overrides)
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "deleteChannelOverwrite",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}/channels/{channel_id}/overwrites/{target_type}/{target_id}",
|
||
Summary: "Снять права комнаты для роли или участника",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
ChannelID string `path:"channel_id"`
|
||
TargetType string `path:"target_type" enum:"role,user"`
|
||
TargetID string `path:"target_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, session, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelID, err := parseID("channel_id", input.ChannelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
targetID, err := parseID("target_id", input.TargetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireOverwriteTarget(ctx, guildID, input.TargetType, targetID); err != nil {
|
||
return nil, err
|
||
}
|
||
// Подтверждение личности берём из недавнего step-up: тело у DELETE
|
||
// необязательно, а клиент уже подтверждал личность перед изменением.
|
||
if err := s.auth.RequireStepUp(ctx, user, session, "", ""); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
removed, err := s.store.DeleteChannelOverride(ctx, channelID, input.TargetType, targetID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if removed {
|
||
s.afterOverwriteChange(ctx, user, guildID, channelID, "channel.overwrite_delete",
|
||
input.TargetType, targetID)
|
||
}
|
||
return newOKOutput(), nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "deleteChannel",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}/channels/{channel_id}",
|
||
Summary: "Удалить комнату",
|
||
Tags: []string{"Channels"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
ChannelID string `path:"channel_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelID, err := parseID("channel_id", input.ChannelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.store.DeleteChannel(ctx, channelID); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "channel.delete", "channel", &channelID, "")
|
||
s.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{
|
||
"channel_id": formatSnowflake(channelID),
|
||
"guild_id": formatSnowflake(guildID),
|
||
})
|
||
// Удалили комнату — у части участников могли остаться права на неё,
|
||
// поэтому список перечитывают все (AGENT.md 11.6).
|
||
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
})
|
||
return newOKOutput(), nil
|
||
})
|
||
}
|
||
|
||
// requireChannelInGuild проверяет, что комната принадлежит серверу (AGENT.md 9.7).
|
||
func (s *Server) requireChannelInGuild(ctx context.Context, guildID, channelID uint64) error {
|
||
channel, err := s.store.GetChannel(ctx, channelID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
if channel.GuildID == nil || *channel.GuildID != guildID {
|
||
return humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// requireOverwriteTarget проверяет цель оверрайда: роль должна принадлежать
|
||
// серверу, участник — состоять в нём (AGENT.md 6.2).
|
||
func (s *Server) requireOverwriteTarget(ctx context.Context, guildID uint64, targetType string, targetID uint64) error {
|
||
switch targetType {
|
||
case "role":
|
||
roles, err := s.store.ListGuildRoles(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
for _, role := range roles {
|
||
if role.ID == targetID {
|
||
return nil
|
||
}
|
||
}
|
||
return humaErrorStatus(http.StatusNotFound, "not_found", "role not found in this guild")
|
||
case "user":
|
||
member, err := s.store.GetGuildMember(ctx, guildID, targetID)
|
||
if err != nil || member == nil {
|
||
return humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this guild")
|
||
}
|
||
return nil
|
||
default:
|
||
return humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "target_type must be role or user")
|
||
}
|
||
}
|
||
|
||
// afterOverwriteChange сбрасывает кэш прав комнаты, пишет аудит и рассылает
|
||
// события: клиенты должны перечитать список комнат, потому что видимость
|
||
// комнаты меняется не только у автора правки (AGENT.md 6.2, 11.6).
|
||
func (s *Server) afterOverwriteChange(ctx context.Context, actor *store.User, guildID, channelID uint64, action, targetType string, targetID uint64) {
|
||
if s.perms != nil {
|
||
s.perms.InvalidateChannel(channelID)
|
||
}
|
||
s.recordAudit(ctx, actor, guildID, action, targetType, &targetID, "")
|
||
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
})
|
||
}
|
||
|
||
// visibleChannels возвращает комнаты сервера, которые пользователь видит.
|
||
func (s *Server) visibleChannels(ctx context.Context, guildID uint64, user *store.User) ([]channelPayload, error) {
|
||
channels, err := s.store.ListGuildChannels(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Оверрайды всех комнат сервера — одним запросом: список комнат не должен
|
||
// превращаться в N+1 (AGENT.md 6.2).
|
||
overrides, err := s.store.ListGuildChannelOverrides(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
visible := make([]channelPayload, 0, len(channels))
|
||
for _, channel := range channels {
|
||
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if !resolved.CanViewChannel() {
|
||
continue
|
||
}
|
||
visible = append(visible, channelFromStore(channel, &resolved, overrides[channel.ID]))
|
||
}
|
||
return visible, nil
|
||
}
|
||
|
||
// guildOutput собирает подробную карточку сервера с ролями и комнатами.
|
||
func (s *Server) guildOutput(ctx context.Context, user *store.User, guild store.Guild) (*guildOutput, error) {
|
||
resolved, err := s.guildPermissions(ctx, guild.ID, user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
payload := guildDetailPayload{
|
||
ID: formatSnowflake(guild.ID),
|
||
Name: guild.Name,
|
||
Description: guild.Description,
|
||
OwnerID: formatSnowflake(guild.OwnerID),
|
||
IsMain: guild.IsMain,
|
||
MyRoleIDs: []string{},
|
||
MyPermissions: permissions.Names(resolved.Guild),
|
||
Roles: []rolePayload{},
|
||
}
|
||
if guild.IconFileID != nil {
|
||
payload.IconFileID = formatSnowflake(*guild.IconFileID)
|
||
}
|
||
if guild.BannerFileID != nil {
|
||
payload.BannerFileID = formatSnowflake(*guild.BannerFileID)
|
||
}
|
||
if guild.SplashFileID != nil {
|
||
payload.SplashFileID = formatSnowflake(*guild.SplashFileID)
|
||
}
|
||
payload.AccentColor = guild.AccentColor
|
||
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
for _, roleID := range roleIDs {
|
||
payload.MyRoleIDs = append(payload.MyRoleIDs, formatSnowflake(roleID))
|
||
}
|
||
roles, err := s.store.ListGuildRoles(ctx, guild.ID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
for _, role := range roles {
|
||
payload.Roles = append(payload.Roles, roleFromStore(role))
|
||
}
|
||
channels, err := s.visibleChannels(ctx, guild.ID, user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
payload.Channels = channels
|
||
count, err := s.store.CountGuildMembers(ctx, guild.ID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
payload.MemberCount = count
|
||
output := &guildOutput{}
|
||
output.Body.Guild = payload
|
||
return output, nil
|
||
}
|
||
|
||
// dispatchGuildEvent рассылает событие всем подключённым клиентам. Клиент
|
||
// сам решает, относится ли событие к его серверу: в Фазе 1 подписки на
|
||
// серверы не отслеживаются, фильтрация появится вместе с чатом (AGENT.md 8.3).
|
||
func (s *Server) dispatchGuildEvent(_ uint64, event string, payload any) {
|
||
if s.gateway == nil {
|
||
return
|
||
}
|
||
s.gateway.Dispatch(event, payload)
|
||
}
|
||
|
||
func (s *Server) invalidateGuild(guildID uint64) {
|
||
if s.perms != nil {
|
||
s.perms.InvalidateGuild(guildID)
|
||
}
|
||
}
|
||
|
||
func (s *Server) registerMemberRoutes(api huma.API) {
|
||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "listGuildMembers",
|
||
Method: http.MethodGet,
|
||
Path: "/guilds/{guild_id}/members",
|
||
Summary: "Участники сервера",
|
||
Tags: []string{"Members"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
Limit int `query:"limit" default:"100" minimum:"1" maximum:"1000"`
|
||
},
|
||
) (*memberListOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
profiles, err := s.store.ListGuildMemberProfiles(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Оформление считается поэлементно для всего сервера (AGENT.md 7.2).
|
||
cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
output := &memberListOutput{}
|
||
output.Body.Members = make([]memberPayload, 0, len(profiles))
|
||
for _, profile := range profiles {
|
||
if len(output.Body.Members) >= input.Limit {
|
||
break
|
||
}
|
||
payload := memberFromProfile(profile)
|
||
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID])
|
||
output.Body.Members = append(output.Body.Members, payload)
|
||
}
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "updateGuildMember",
|
||
Method: http.MethodPatch,
|
||
Path: "/guilds/{guild_id}/members/{user_id}",
|
||
Summary: "Никнейм и тайм-аут участника",
|
||
Tags: []string{"Members"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
UserID string `path:"user_id"`
|
||
Body struct {
|
||
Nickname *string `json:"nickname,omitempty" maxLength:"32"`
|
||
TimeoutUntil *string `json:"timeout_until,omitempty"`
|
||
}
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
targetID, err := parseID("user_id", input.UserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Свой никнейм участник меняет сам: иерархия и MANAGE_NICKNAMES для
|
||
// этого не нужны (AGENT.md 7.2, 7.17). Тайм-аут на себя — запрещён.
|
||
selfEdit := targetID == user.ID
|
||
if !selfEdit {
|
||
if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
var (
|
||
nickname *string
|
||
timeoutUntil *time.Time
|
||
)
|
||
if input.Body.Nickname != nil {
|
||
if !selfEdit && !resolved.Has(permissions.ManageNicknames) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MANAGE_NICKNAMES is required")
|
||
}
|
||
trimmed := strings.TrimSpace(*input.Body.Nickname)
|
||
nickname = &trimmed
|
||
}
|
||
if input.Body.TimeoutUntil != nil {
|
||
if selfEdit {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot time yourself out")
|
||
}
|
||
if !resolved.Has(permissions.TimeoutMembers) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "TIMEOUT_MEMBERS is required")
|
||
}
|
||
parsed, err := time.Parse(time.RFC3339, *input.Body.TimeoutUntil)
|
||
if err != nil {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "timeout_until must be RFC3339")
|
||
}
|
||
timeoutUntil = &parsed
|
||
}
|
||
if err := s.store.UpdateGuildMember(ctx, guildID, targetID, nickname, timeoutUntil); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
// Ник и тайм-аут участника тоже действия модерации: журнал сервера
|
||
// должен показывать и их (AGENT.md 11.5).
|
||
if nickname != nil {
|
||
s.recordAudit(ctx, user, guildID, "member.update", "user", &targetID, "")
|
||
}
|
||
if timeoutUntil != nil {
|
||
s.recordAudit(ctx, user, guildID, "member.timeout", "user", &targetID,
|
||
"until "+timeoutUntil.UTC().Format(time.RFC3339))
|
||
}
|
||
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
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "kickGuildMember",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}/members/{user_id}",
|
||
Summary: "Исключить участника",
|
||
Tags: []string{"Members"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
UserID string `path:"user_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.KickMembers)
|
||
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, user, resolved, targetID); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Системная запись об исключении участника.
|
||
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_kick", map[string]any{
|
||
"user_id": formatSnowflake(targetID),
|
||
})
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "")
|
||
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 newOKOutput(), nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "addMemberRole",
|
||
Method: http.MethodPut,
|
||
Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}",
|
||
Summary: "Выдать роль участнику",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
UserID string `path:"user_id"`
|
||
RoleID string `path:"role_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, true)
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "removeMemberRole",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}",
|
||
Summary: "Снять роль с участника",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
UserID string `path:"user_id"`
|
||
RoleID string `path:"role_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, false)
|
||
})
|
||
}
|
||
|
||
// changeMemberRole выдаёт или снимает роль с проверкой иерархии (AGENT.md 6.3).
|
||
func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, roleIDRaw string, assign bool) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, guildIDRaw, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
targetID, err := parseID("user_id", userIDRaw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
roleID, err := parseID("role_id", roleIDRaw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Роли на себя выдавать можно (MANAGE_ROLES уже проверено): запрет
|
||
// самомодерации касается кика, бана и тайм-аута, а не оформления.
|
||
if err := s.checkRoleHierarchy(ctx, guildID, user, resolved, targetID); err != nil {
|
||
return nil, err
|
||
}
|
||
role, err := s.store.GetRole(ctx, roleID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if role.GuildID != guildID {
|
||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
|
||
}
|
||
if !s.outranksRole(ctx, guildID, user, resolved, role) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
|
||
}
|
||
if role.IsDefault {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role is assigned to everyone")
|
||
}
|
||
if assign {
|
||
err = s.store.AssignRole(ctx, guildID, targetID, roleID)
|
||
} else {
|
||
err = s.store.RemoveRole(ctx, guildID, targetID, roleID)
|
||
}
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
action := "member.role_add"
|
||
if !assign {
|
||
action = "member.role_remove"
|
||
}
|
||
s.recordAudit(ctx, user, guildID, action, "role", &roleID, "")
|
||
// Смена ролей — это обновление участника (AGENT.md 8.3: MEMBER_UPDATE).
|
||
roleIDs, err := s.store.MemberRoleIDs(ctx, guildID, targetID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
formatted := make([]string, 0, len(roleIDs))
|
||
for _, id := range roleIDs {
|
||
formatted = append(formatted, formatSnowflake(id))
|
||
}
|
||
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
"user_id": formatSnowflake(targetID),
|
||
"role_ids": formatted,
|
||
"role_id": formatSnowflake(roleID),
|
||
"role_added": assign,
|
||
})
|
||
return newOKOutput(), nil
|
||
}
|
||
|
||
// checkRoleHierarchy — проверка иерархии для выдачи и снятия ролей: в отличие
|
||
// от модерации, свои роли менять можно (владелец сервера не теряет владение),
|
||
// но владельца и администратора инстанса по-прежнему не трогаем.
|
||
func (s *Server) checkRoleHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error {
|
||
if actor.IsInstanceAdmin {
|
||
return nil
|
||
}
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
if targetID == guild.OwnerID && actor.ID != guild.OwnerID {
|
||
return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated")
|
||
}
|
||
if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin && actor.ID != targetID {
|
||
return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated")
|
||
}
|
||
if targetID == actor.ID {
|
||
return nil
|
||
}
|
||
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
targetRoles, err := s.roleData(ctx, guildID, targetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetID == guild.OwnerID) {
|
||
return humaErrorStatus(http.StatusForbidden, "perm.denied", "member has a higher role")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkMemberHierarchy запрещает действовать против владельца и участников с
|
||
// более высокими ролями (AGENT.md 6.3, 7.17).
|
||
func (s *Server) checkMemberHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error {
|
||
// Модерировать себя нельзя никому, включая администратора инстанса: иначе
|
||
// он может случайно исключить или забанить сам себя (проверено на стенде).
|
||
if targetID == actor.ID {
|
||
return humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot moderate yourself")
|
||
}
|
||
if actor.IsInstanceAdmin {
|
||
return nil
|
||
}
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
// Администратора инстанса нельзя модерировать с сервера: его права
|
||
// глобальны и не могут быть отозваны владельцем сервера (AGENT.md 7.19).
|
||
if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin {
|
||
return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated")
|
||
}
|
||
if targetID == guild.OwnerID && actor.ID != guild.OwnerID {
|
||
return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated")
|
||
}
|
||
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
targetRoles, err := s.roleData(ctx, guildID, targetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
targetIsOwner := targetID == guild.OwnerID
|
||
if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetIsOwner) {
|
||
return humaErrorStatus(http.StatusForbidden, "perm.denied", "target member has an equal or higher role")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// outranksRole проверяет, что роль ниже роли действующего пользователя.
|
||
func (s *Server) outranksRole(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, role *store.Role) bool {
|
||
if actor.IsInstanceAdmin || resolved.IsOwner {
|
||
return true
|
||
}
|
||
actorRoles, err := s.roleData(ctx, guildID, actor.ID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return permissions.OutranksRole(actorRoles, permissions.RoleData{
|
||
ID: role.ID,
|
||
Permissions: permissions.Permission(role.Permissions),
|
||
IsDefault: role.IsDefault,
|
||
Position: role.Position,
|
||
}, false)
|
||
}
|
||
|
||
// roleData переводит роли участника в данные движка прав.
|
||
func (s *Server) roleData(ctx context.Context, guildID, userID uint64) ([]permissions.RoleData, error) {
|
||
roles, err := s.store.MemberRoles(ctx, guildID, userID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
result := make([]permissions.RoleData, 0, len(roles))
|
||
for _, role := range roles {
|
||
result = append(result, permissions.RoleData{
|
||
ID: role.ID,
|
||
Permissions: permissions.Permission(role.Permissions),
|
||
IsDefault: role.IsDefault,
|
||
Position: role.Position,
|
||
})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Server) registerRoleRoutes(api huma.API) {
|
||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "listGuildRoles",
|
||
Method: http.MethodGet,
|
||
Path: "/guilds/{guild_id}/roles",
|
||
Summary: "Роли сервера",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
},
|
||
) (*roleListOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
roles, err := s.store.ListGuildRoles(ctx, guildID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
output := &roleListOutput{}
|
||
output.Body.Roles = make([]rolePayload, 0, len(roles))
|
||
for _, role := range roles {
|
||
output.Body.Roles = append(output.Body.Roles, roleFromStore(role))
|
||
}
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "createGuildRole",
|
||
Method: http.MethodPost,
|
||
Path: "/guilds/{guild_id}/roles",
|
||
Summary: "Создать роль",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
Body struct {
|
||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||
Permissions string `json:"permissions,omitempty"`
|
||
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
|
||
Hoist bool `json:"hoist,omitempty"`
|
||
Mentionable bool `json:"mentionable,omitempty"`
|
||
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
|
||
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
|
||
NickColor *int64 `json:"nick_color,omitempty"`
|
||
NickEffect string `json:"nick_effect,omitempty"`
|
||
}
|
||
},
|
||
) (*roleOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
permissionSet, err := permissions.Parse(input.Body.Permissions)
|
||
if err != nil {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
|
||
}
|
||
if !s.canGrantPermissions(ctx, guildID, user, permissionSet) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have")
|
||
}
|
||
name := strings.TrimSpace(input.Body.Name)
|
||
if name == "" {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "role name must not be empty")
|
||
}
|
||
role, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
||
GuildID: guildID,
|
||
Name: name,
|
||
Permissions: uint64(permissionSet),
|
||
Color: input.Body.Color,
|
||
Hoist: input.Body.Hoist,
|
||
Mentionable: input.Body.Mentionable,
|
||
})
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки.
|
||
if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{
|
||
FrameID: input.Body.CosmeticFrameID,
|
||
BadgeID: input.Body.CosmeticBadgeID,
|
||
NickColor: input.Body.NickColor,
|
||
NickEffect: input.Body.NickEffect,
|
||
FrameSet: input.Body.CosmeticFrameID != "",
|
||
BadgeSet: input.Body.CosmeticBadgeID != "",
|
||
ColorSet: input.Body.NickColor != nil,
|
||
EffectSet: input.Body.NickEffect != "",
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
|
||
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
|
||
output := &roleOutput{}
|
||
output.Body.Role = roleFromStore(*role)
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "updateGuildRole",
|
||
Method: http.MethodPatch,
|
||
Path: "/guilds/{guild_id}/roles/{role_id}",
|
||
Summary: "Изменить роль",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
RoleID string `path:"role_id"`
|
||
Body struct {
|
||
Name *string `json:"name,omitempty" maxLength:"64"`
|
||
Permissions *string `json:"permissions,omitempty"`
|
||
Color *int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
|
||
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
|
||
Hoist *bool `json:"hoist,omitempty"`
|
||
Mentionable *bool `json:"mentionable,omitempty"`
|
||
// Оформление роли (AGENT.md 7.4): пустая строка снимает элемент.
|
||
CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"`
|
||
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
|
||
NickColor *int64 `json:"nick_color,omitempty"`
|
||
NickEffect *string `json:"nick_effect,omitempty"`
|
||
// Step-up обязателен при смене прав роли (AGENT.md 9.3).
|
||
StepUpPassword string `json:"step_up_password,omitempty"`
|
||
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||
}
|
||
},
|
||
) (*roleOutput, error) {
|
||
user, session, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Смена набора прав роли — чувствительная операция: подтверждаем
|
||
// личность свежим паролем (AGENT.md 9.3).
|
||
if input.Body.Permissions != nil {
|
||
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
}
|
||
roleID, err := parseID("role_id", input.RoleID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
role, err := s.store.GetRole(ctx, roleID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if role.GuildID != guildID {
|
||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
|
||
}
|
||
if !s.outranksRole(ctx, guildID, user, resolved, role) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
|
||
}
|
||
params := store.UpdateRoleParams{
|
||
Name: input.Body.Name,
|
||
Color: input.Body.Color,
|
||
Position: input.Body.Position,
|
||
Hoist: input.Body.Hoist,
|
||
Mentionable: input.Body.Mentionable,
|
||
}
|
||
if input.Body.Permissions != nil {
|
||
permissionSet, err := permissions.Parse(*input.Body.Permissions)
|
||
if err != nil {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error())
|
||
}
|
||
if !s.canGrantPermissions(ctx, guildID, user, permissionSet) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have")
|
||
}
|
||
value := uint64(permissionSet)
|
||
params.Permissions = &value
|
||
}
|
||
updated, err := s.store.UpdateRole(ctx, roleID, params)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
|
||
if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{
|
||
FrameID: optionalString(input.Body.CosmeticFrameID),
|
||
BadgeID: optionalString(input.Body.CosmeticBadgeID),
|
||
NickColor: input.Body.NickColor,
|
||
NickEffect: optionalString(input.Body.NickEffect),
|
||
FrameSet: input.Body.CosmeticFrameID != nil,
|
||
BadgeSet: input.Body.CosmeticBadgeID != nil,
|
||
ColorSet: input.Body.NickColor != nil,
|
||
EffectSet: input.Body.NickEffect != nil,
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
|
||
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))
|
||
output := &roleOutput{}
|
||
output.Body.Role = roleFromStore(*updated)
|
||
return output, nil
|
||
})
|
||
|
||
huma.Register(api, huma.Operation{
|
||
OperationID: "deleteGuildRole",
|
||
Method: http.MethodDelete,
|
||
Path: "/guilds/{guild_id}/roles/{role_id}",
|
||
Summary: "Удалить роль",
|
||
Tags: []string{"Roles"},
|
||
Security: security,
|
||
}, func(ctx context.Context, input *struct {
|
||
GuildID string `path:"guild_id"`
|
||
RoleID string `path:"role_id"`
|
||
},
|
||
) (*okOutput, error) {
|
||
user, _, err := requireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
roleID, err := parseID("role_id", input.RoleID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
role, err := s.store.GetRole(ctx, roleID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if role.GuildID != guildID {
|
||
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found")
|
||
}
|
||
if role.IsDefault {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role cannot be deleted")
|
||
}
|
||
if !s.outranksRole(ctx, guildID, user, resolved, role) {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours")
|
||
}
|
||
if err := s.store.DeleteRole(ctx, roleID); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, user, guildID, "role.delete", "role", &roleID, "")
|
||
s.dispatchGuildEvent(guildID, "ROLE_DELETE", map[string]any{
|
||
"guild_id": formatSnowflake(guildID),
|
||
"role_id": formatSnowflake(roleID),
|
||
})
|
||
return newOKOutput(), nil
|
||
})
|
||
}
|
||
|
||
// canGrantPermissions запрещает выдавать права выше собственных (AGENT.md 6.3).
|
||
func (s *Server) canGrantPermissions(ctx context.Context, guildID uint64, user *store.User, requested permissions.Permission) bool {
|
||
if user.IsInstanceAdmin {
|
||
return true
|
||
}
|
||
resolved, err := s.guildPermissions(ctx, guildID, user)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if resolved.IsOwner || resolved.Guild.Has(permissions.Administrator) {
|
||
return true
|
||
}
|
||
return resolved.Guild.Has(requested)
|
||
}
|
||
|
||
// memberFromProfile собирает участника для ответа API.
|
||
func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
|
||
payload := memberPayload{
|
||
UserID: formatSnowflake(profile.UserID),
|
||
Username: profile.Username,
|
||
DisplayName: profile.DisplayName,
|
||
Nickname: profile.Nickname,
|
||
Status: profile.Status,
|
||
CustomStatus: profile.CustomStatus,
|
||
IsInstanceAdmin: profile.IsInstanceAdmin,
|
||
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
|
||
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
|
||
Badges: profile.Badges,
|
||
}
|
||
if payload.Badges == nil {
|
||
payload.Badges = []string{}
|
||
}
|
||
if profile.AvatarFileID != nil {
|
||
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
||
}
|
||
// Истёкший тайм-аут не показываем: он уже не действует.
|
||
if profile.TimeoutUntil != nil && profile.TimeoutUntil.After(time.Now().UTC()) {
|
||
payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339)
|
||
}
|
||
for _, roleID := range profile.RoleIDs {
|
||
payload.RoleIDs = append(payload.RoleIDs, formatSnowflake(roleID))
|
||
}
|
||
return payload
|
||
}
|
||
|
||
type auditEntryPayload struct {
|
||
ID string `json:"id"`
|
||
GuildID string `json:"guild_id,omitempty"`
|
||
ActorID string `json:"actor_id,omitempty"`
|
||
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||
Action string `json:"action"`
|
||
TargetType string `json:"target_type"`
|
||
TargetID string `json:"target_id,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
Changes json.RawMessage `json:"changes,omitempty"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
type auditListOutput struct {
|
||
Body struct {
|
||
Entries []auditEntryPayload `json:"entries"`
|
||
}
|
||
}
|
||
|
||
func auditPayloads(entries []store.AuditEntry) []auditEntryPayload {
|
||
payloads := make([]auditEntryPayload, 0, len(entries))
|
||
for _, entry := range entries {
|
||
payload := auditEntryPayload{
|
||
ID: formatSnowflake(entry.ID),
|
||
Action: entry.Action,
|
||
TargetType: entry.TargetType,
|
||
Reason: entry.Reason,
|
||
Changes: entry.Changes,
|
||
CreatedAt: entry.CreatedAt.UTC().Format(time.RFC3339),
|
||
ActorInstanceAdmin: entry.ActorInstanceAdmin,
|
||
}
|
||
if entry.GuildID != nil {
|
||
payload.GuildID = formatSnowflake(*entry.GuildID)
|
||
}
|
||
if entry.ActorID != nil {
|
||
payload.ActorID = formatSnowflake(*entry.ActorID)
|
||
}
|
||
if entry.TargetID != nil {
|
||
payload.TargetID = formatSnowflake(*entry.TargetID)
|
||
}
|
||
payloads = append(payloads, payload)
|
||
}
|
||
return payloads
|
||
}
|
||
|
||
// postSystemMessage создаёт системное сообщение в комнате: лента показывает
|
||
// такие записи отдельным стилем (AGENT.md 7.6).
|
||
func (s *Server) postSystemMessage(ctx context.Context, channelID uint64, kind string, payload map[string]any) {
|
||
if channelID == 0 {
|
||
return
|
||
}
|
||
data := map[string]any{"event": kind}
|
||
for key, value := range payload {
|
||
data[key] = value
|
||
}
|
||
encoded, err := json.Marshal(data)
|
||
if err != nil {
|
||
return
|
||
}
|
||
message, err := s.store.CreateMessage(ctx, store.CreateMessageParams{
|
||
ChannelID: channelID,
|
||
AuthorID: 0,
|
||
Content: string(encoded),
|
||
Type: store.MessageSystem,
|
||
})
|
||
if err != nil {
|
||
s.logger.DebugContext(ctx, "failed to post system message", slog.Any("error", err))
|
||
return
|
||
}
|
||
if s.gateway == nil {
|
||
return
|
||
}
|
||
payloadMessage, err := s.messagePayload(ctx, message, 0)
|
||
if err != nil {
|
||
return
|
||
}
|
||
s.gateway.DispatchToChannel(ctx, channelID, "MESSAGE_CREATE", payloadMessage)
|
||
}
|
||
|
||
// systemChannelID выбирает комнату для системных записей: первая текстовая.
|
||
func (s *Server) systemChannelID(ctx context.Context, guildID uint64) uint64 {
|
||
channels, err := s.store.ListGuildChannels(ctx, guildID)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
for _, channel := range channels {
|
||
if channel.Type == store.ChannelText {
|
||
return channel.ID
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// recordAudit пишет действие в журнал; ошибка записи не ломает операцию.
|
||
func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) {
|
||
var guild *uint64
|
||
if guildID != 0 {
|
||
guild = &guildID
|
||
}
|
||
params := store.AuditParams{
|
||
GuildID: guild,
|
||
ActorID: &actor.ID,
|
||
ActorInstanceAdmin: actor.IsInstanceAdmin,
|
||
Action: action,
|
||
TargetType: targetType,
|
||
TargetID: targetID,
|
||
Reason: reason,
|
||
}
|
||
if err := s.store.RecordAudit(ctx, params); err != nil {
|
||
s.logger.WarnContext(ctx, "failed to record audit entry",
|
||
slog.String("action", action), slog.Any("error", err))
|
||
}
|
||
}
|
||
|
||
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
|
||
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
|
||
template, known := findGuildTemplate(templateID)
|
||
if !known {
|
||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||
}
|
||
settings, err := s.store.InstanceSettings(ctx)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Лимиты считаются по числу серверов; администратор инстанса обходит их
|
||
// осознанно, и это фиксируется в аудите (AGENT.md 6.5).
|
||
created, err := s.store.CountGuildMemberships(ctx, owner.ID)
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
bypassed := false
|
||
if created >= settings.MaxGuildsPerUser {
|
||
if !owner.IsInstanceAdmin {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "limits.guilds_reached", "guild limit for this account is reached")
|
||
}
|
||
bypassed = true
|
||
} else if !settings.AllowGuildCreation && !owner.IsInstanceAdmin {
|
||
return nil, humaErrorStatus(http.StatusForbidden, "limits.guild_creation_disabled", "guild creation is disabled")
|
||
}
|
||
|
||
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
|
||
Name: name,
|
||
OwnerID: owner.ID,
|
||
// Пользовательский сервер становится главным только если главного нет.
|
||
IsMain: settings.MainGuildID == 0,
|
||
IsDiscoverable: false,
|
||
})
|
||
if err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
// Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3).
|
||
if err := s.applyGuildTemplate(ctx, guild, template); err != nil {
|
||
return nil, err
|
||
}
|
||
if guild.IsMain {
|
||
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
||
return nil, humaError(err)
|
||
}
|
||
}
|
||
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
|
||
if bypassed {
|
||
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
||
}
|
||
if s.gateway != nil {
|
||
s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)})
|
||
}
|
||
return guild, nil
|
||
}
|
||
|
||
// joinGuild добавляет пользователя в открытый сервер: главный сервер открыт
|
||
// всегда, остальные — если помечены как discoverable (AGENT.md 6.3).
|
||
func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64) error {
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin {
|
||
return humaErrorStatus(http.StatusForbidden, "guild.invite_required", "this guild requires an invite")
|
||
}
|
||
// Бан сервера закрывает вход и в публичный сервер: иначе достаточно было бы
|
||
// нажать «Войти» вместо приглашения (AGENT.md 7.17, 7.20).
|
||
if banned, err := s.store.IsGuildBanned(ctx, guildID, user.ID); err != nil {
|
||
return humaError(err)
|
||
} else if banned {
|
||
return humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
|
||
}
|
||
settings, err := s.store.InstanceSettings(ctx)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
count, err := s.store.CountGuildMembers(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin {
|
||
return humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full")
|
||
}
|
||
if _, err := s.store.AddGuildMember(ctx, guildID, user.ID, ""); err != nil {
|
||
if errors.Is(err, store.ErrConflict) {
|
||
return nil
|
||
}
|
||
return humaError(err)
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_join", map[string]any{
|
||
"user_id": formatSnowflake(user.ID),
|
||
})
|
||
s.recordAudit(ctx, user, guildID, "member.join", "user", &user.ID, "")
|
||
if s.gateway != nil {
|
||
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)})
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// dispatchGuildUpdate рассылает GUILD_UPDATE со всеми полями оформления:
|
||
// клиенты обновляют иконку, баннер, splash и акцент без перезагрузки
|
||
// (AGENT.md 7.4, 11.6).
|
||
func (s *Server) dispatchGuildUpdate(guild store.Guild) {
|
||
if s.gateway == nil {
|
||
return
|
||
}
|
||
payload := map[string]any{
|
||
"guild_id": formatSnowflake(guild.ID),
|
||
"name": guild.Name,
|
||
"description": guild.Description,
|
||
"accent_color": guild.AccentColor,
|
||
}
|
||
if guild.IconFileID != nil {
|
||
payload["icon_file_id"] = formatSnowflake(*guild.IconFileID)
|
||
} else {
|
||
payload["icon_file_id"] = nil
|
||
}
|
||
if guild.BannerFileID != nil {
|
||
payload["banner_file_id"] = formatSnowflake(*guild.BannerFileID)
|
||
} else {
|
||
payload["banner_file_id"] = nil
|
||
}
|
||
if guild.SplashFileID != nil {
|
||
payload["splash_file_id"] = formatSnowflake(*guild.SplashFileID)
|
||
} else {
|
||
payload["splash_file_id"] = nil
|
||
}
|
||
s.gateway.Dispatch("GUILD_UPDATE", payload)
|
||
}
|
||
|
||
// deleteGuild удаляет сервер: главный сервер удалить нельзя (AGENT.md 6.3).
|
||
func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uint64) error {
|
||
guild, err := s.store.GetGuild(ctx, guildID)
|
||
if err != nil {
|
||
return humaError(err)
|
||
}
|
||
if guild.IsMain {
|
||
return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted")
|
||
}
|
||
// Пути файлов собираем до удаления: записи уходят каскадом, а содержимое
|
||
// на диске осталось бы мусором (AGENT.md 6.4).
|
||
storagePaths, err := s.store.GuildStoragePaths(ctx, guildID)
|
||
if err != nil {
|
||
s.logger.WarnContext(ctx, "failed to list guild files",
|
||
slog.String("guild_id", formatSnowflake(guildID)), slog.Any("error", err))
|
||
}
|
||
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
|
||
return humaError(err)
|
||
}
|
||
for _, path := range storagePaths {
|
||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
s.logger.WarnContext(ctx, "failed to remove guild file",
|
||
slog.String("path", path), slog.Any("error", err))
|
||
}
|
||
}
|
||
s.invalidateGuild(guildID)
|
||
s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "")
|
||
if s.gateway != nil {
|
||
s.gateway.Dispatch("GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)})
|
||
}
|
||
return nil
|
||
}
|