feat(guilds): приватные комнаты — права доступа поверх серверных

Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
  оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
  (права именами через `|`, как у ролей), step-up на изменение, проверка что
  роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
  прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
  GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.

Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
  VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
  участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
  только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.

Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
This commit is contained in:
2026-09-22 19:14:05 +03:00
parent b403f6c894
commit 785af0dc7b
16 changed files with 1569 additions and 219 deletions
+211 -6
View File
@@ -33,6 +33,30 @@ type channelPayload struct {
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 {
@@ -86,7 +110,7 @@ type guildDetailPayload struct {
Channels []channelPayload `json:"channels,omitempty"`
}
func channelFromStore(channel store.Channel, resolved *permissions.Resolved) channelPayload {
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
payload := channelPayload{
ID: formatSnowflake(channel.ID),
Type: string(channel.Type),
@@ -110,6 +134,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
payload.CanSend = resolved.Can(permissions.SendMessages)
payload.CanConnect = resolved.Can(permissions.ConnectVoice)
}
payload.PermissionOverwrites = channelOverwritePayloads(overrides)
return payload
}
@@ -560,14 +585,14 @@ func (s *Server) registerChannelRoutes(api huma.API) {
"type": string(channel.Type),
})
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil))
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil, 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)
output.Body.Channel = channelFromStore(*channel, &resolved, nil)
return output, nil
})
@@ -617,16 +642,151 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil))
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil, 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)
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,
@@ -678,12 +838,57 @@ func (s *Server) requireChannelInGuild(ctx context.Context, guildID, channelID u
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)
@@ -693,7 +898,7 @@ func (s *Server) visibleChannels(ctx context.Context, guildID uint64, user *stor
if !resolved.CanViewChannel() {
continue
}
visible = append(visible, channelFromStore(channel, &resolved))
visible = append(visible, channelFromStore(channel, &resolved, overrides[channel.ID]))
}
return visible, nil
}