0a82dd3a02
- установщик: `[DEFAULT] ignoreip` в jail.local — локальные сети и WireGuard исключены всегда, свой адрес добавляется флагом `--ignore-ip <ip|cidr>`; админ, часто заходящий в панель, больше не банит сам себя; - участник может менять свой никнейм на сервере без MANAGE_NICKNAMES и без проверки иерархии (тайм-аут на себя по-прежнему запрещён); - тест на смену своего ника и запрет self-тайм-аута.
1416 lines
47 KiB
Go
1416 lines
47 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"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"`
|
|
}
|
|
|
|
type rolePayload struct {
|
|
ID string `json:"id"`
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
func channelFromStore(channel store.Channel, resolved *permissions.Resolved) 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 resolved != nil {
|
|
payload.CanView = resolved.CanViewChannel()
|
|
payload.CanSend = resolved.Can(permissions.SendMessages)
|
|
payload.CanConnect = resolved.Can(permissions.ConnectVoice)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func roleFromStore(role store.Role) rolePayload {
|
|
return rolePayload{
|
|
ID: formatSnowflake(role.ID),
|
|
Name: role.Name,
|
|
Color: role.Color,
|
|
Position: role.Position,
|
|
Permissions: permissions.Permission(role.Permissions).String(),
|
|
IsDefault: role.IsDefault,
|
|
Hoist: role.Hoist,
|
|
Mentionable: role.Mentionable,
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
},
|
|
) (*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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.guildOutput(ctx, user, *guild)
|
|
})
|
|
|
|
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"`
|
|
}
|
|
},
|
|
) (*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}
|
|
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)
|
|
if s.gateway != nil {
|
|
s.gateway.Dispatch("GUILD_UPDATE", map[string]any{
|
|
"guild_id": formatSnowflake(guildID),
|
|
"name": guild.Name,
|
|
})
|
|
}
|
|
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, _, 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")
|
|
}
|
|
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"`
|
|
},
|
|
) (*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
|
|
}
|
|
entries, err := s.store.ListAuditLog(ctx, guildID, input.Limit)
|
|
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)
|
|
}
|
|
s.invalidateGuild(guildID)
|
|
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil))
|
|
// Права на новую комнату считаем для автора запроса.
|
|
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)
|
|
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.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil))
|
|
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)
|
|
return output, 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.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{
|
|
"channel_id": formatSnowflake(channelID),
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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))
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
output := &memberListOutput{}
|
|
output.Body.Members = make([]memberPayload, 0, len(profiles))
|
|
for _, profile := range profiles {
|
|
if len(output.Body.Members) >= input.Limit {
|
|
break
|
|
}
|
|
output.Body.Members = append(output.Body.Members, memberFromProfile(profile))
|
|
}
|
|
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)
|
|
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{
|
|
"guild_id": formatSnowflake(guildID),
|
|
"user_id": formatSnowflake(targetID),
|
|
"nickname": nickname,
|
|
})
|
|
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.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
|
|
}
|
|
if err := s.checkMemberHierarchy(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
|
|
}
|
|
|
|
// 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 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")
|
|
}
|
|
if targetID == actor.ID {
|
|
return humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot moderate yourself")
|
|
}
|
|
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"`
|
|
}
|
|
},
|
|
) (*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)
|
|
}
|
|
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"`
|
|
}
|
|
},
|
|
) (*roleOutput, 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 !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)
|
|
}
|
|
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)),
|
|
}
|
|
if profile.AvatarFileID != nil {
|
|
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
|
}
|
|
if profile.TimeoutUntil != nil {
|
|
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
|
|
}
|
|
|
|
// 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 string) (*store.Guild, error) {
|
|
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)
|
|
}
|
|
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
|
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
|
}); err != nil {
|
|
return nil, humaError(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, "")
|
|
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")
|
|
}
|
|
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.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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
|
|
return humaError(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
|
|
}
|