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:
@@ -832,7 +832,7 @@ func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Re
|
||||
s.invalidateGuild(*channel.GuildID)
|
||||
s.recordAudit(ctx, user, *channel.GuildID, "channel.background", "channel", &channelID, "")
|
||||
}
|
||||
payload := channelFromStore(*updated, nil)
|
||||
payload := channelFromStore(*updated, nil, nil)
|
||||
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
|
||||
}
|
||||
@@ -861,7 +861,7 @@ func (s *Server) handleChannelBackgroundDelete(w http.ResponseWriter, r *http.Re
|
||||
s.invalidateGuild(*channel.GuildID)
|
||||
s.recordAudit(ctx, user, *channel.GuildID, "channel.background_remove", "channel", &channelID, "")
|
||||
}
|
||||
payload := channelFromStore(*updated, nil)
|
||||
payload := channelFromStore(*updated, nil, nil)
|
||||
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// channelIDs забирает видимые комнаты сервера глазами конкретной сессии.
|
||||
func channelIDs(t *testing.T, srv *Server, cookie *http.Cookie, guildID string) []string {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET channels = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channels"`
|
||||
}](t, rec)
|
||||
ids := make([]string, 0, len(payload.Channels))
|
||||
for _, channel := range payload.Channels {
|
||||
ids = append(ids, channel.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func containsID(ids []string, want string) bool {
|
||||
for _, id := range ids {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultRoleID(t *testing.T, srv *Server, cookie *http.Cookie, guildID string) string {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET roles = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"roles"`
|
||||
}](t, rec)
|
||||
for _, role := range payload.Roles {
|
||||
if role.IsDefault {
|
||||
return role.ID
|
||||
}
|
||||
}
|
||||
t.Fatal("default role not found")
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestChannelOverwritesHideAndRevealChannel проверяет приватную комнату
|
||||
// (AGENT.md 6.2, 11.6): запрет @everyone скрывает комнату у участника,
|
||||
// разрешение роли — открывает, снятие оверрайдов возвращает исходное состояние.
|
||||
func TestChannelOverwritesHideAndRevealChannel(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "overwrite_owner", "overwrite-owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "overwrite_member", "overwrite-member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Приватный"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
guildID := guild.Guild.ID
|
||||
|
||||
channelRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels",
|
||||
`{"name":"тайная","type":"text"}`, ownerCookie)
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, channelRec)
|
||||
channelID := channel.Channel.ID
|
||||
|
||||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
|
||||
`{"name":"Доверенные"}`, ownerCookie)
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"role"`
|
||||
}](t, roleRec)
|
||||
roleID := role.Role.ID
|
||||
|
||||
inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites",
|
||||
`{"max_uses":0,"max_age_seconds":3600}`, ownerCookie)
|
||||
invite := decodeResponse[struct {
|
||||
Invite struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"invite"`
|
||||
}](t, inviteRec)
|
||||
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", memberCookie); rec.Code != http.StatusOK {
|
||||
t.Fatalf("accept invite = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
member, err := srv.auth.UserByEmail(t.Context(), "overwrite-member@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
memberPathID := formatSnowflake(member.ID)
|
||||
|
||||
// Пока оверрайдов нет, комната видна всем участникам сервера.
|
||||
if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) {
|
||||
t.Fatalf("member must see the channel before overwrites: %v", ids)
|
||||
}
|
||||
|
||||
const viewMask = "VIEW_CHANNEL"
|
||||
everyoneID := defaultRoleID(t, srv, ownerCookie, guildID)
|
||||
basePath := "/api/v1/guilds/" + guildID + "/channels/" + channelID + "/overwrites/"
|
||||
|
||||
// Участник не управляет правами комнаты.
|
||||
denied := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID,
|
||||
`{"deny":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, memberCookie)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("member overwrite = %d, want 403", denied.Code)
|
||||
}
|
||||
|
||||
// Без подтверждения личности права не меняются.
|
||||
noStepUp := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID,
|
||||
`{"deny":"`+viewMask+`"}`, ownerCookie)
|
||||
if noStepUp.Code != http.StatusForbidden {
|
||||
t.Fatalf("overwrite without step-up = %d, want 403, body = %s", noStepUp.Code, noStepUp.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
|
||||
t.Fatalf("overwrite without step-up code = %q", code)
|
||||
}
|
||||
|
||||
// Закрываем комнату для @everyone: участник её больше не видит.
|
||||
closed := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID,
|
||||
`{"deny":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, ownerCookie)
|
||||
if closed.Code != http.StatusOK {
|
||||
t.Fatalf("close channel = %d, body = %s", closed.Code, closed.Body.String())
|
||||
}
|
||||
closedPayload := decodeResponse[struct {
|
||||
Channel struct {
|
||||
PermissionOverwrites []struct {
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Deny string `json:"deny"`
|
||||
} `json:"permission_overwrites"`
|
||||
} `json:"channel"`
|
||||
}](t, closed)
|
||||
if len(closedPayload.Channel.PermissionOverwrites) != 1 ||
|
||||
closedPayload.Channel.PermissionOverwrites[0].Deny != viewMask {
|
||||
t.Fatalf("overwrites in payload = %+v", closedPayload.Channel.PermissionOverwrites)
|
||||
}
|
||||
if ids := channelIDs(t, srv, memberCookie, guildID); containsID(ids, channelID) {
|
||||
t.Fatalf("private channel must be hidden from member: %v", ids)
|
||||
}
|
||||
// Владелец по-прежнему видит комнату (у него ADMINISTRATOR).
|
||||
if ids := channelIDs(t, srv, ownerCookie, guildID); !containsID(ids, channelID) {
|
||||
t.Fatalf("owner must still see the channel: %v", ids)
|
||||
}
|
||||
|
||||
// Разрешение роли возвращает доступ: выдаём роль участнику и открываем комнату.
|
||||
if rec := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guildID+"/members/"+memberPathID+"/roles/"+roleID, "", ownerCookie); rec.Code != http.StatusOK {
|
||||
t.Fatalf("assign role = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
allow := doJSON(t, srv, http.MethodPut, basePath+"role/"+roleID,
|
||||
`{"allow":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, ownerCookie)
|
||||
if allow.Code != http.StatusOK {
|
||||
t.Fatalf("allow role = %d, body = %s", allow.Code, allow.Body.String())
|
||||
}
|
||||
if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) {
|
||||
t.Fatalf("role allow must open the channel: %v", ids)
|
||||
}
|
||||
|
||||
// Снятие оверрайда с роли снова закрывает комнату (запрет @everyone остался).
|
||||
removed := doJSON(t, srv, http.MethodDelete, basePath+"role/"+roleID, "", ownerCookie)
|
||||
if removed.Code != http.StatusOK {
|
||||
t.Fatalf("delete role overwrite = %d, body = %s", removed.Code, removed.Body.String())
|
||||
}
|
||||
if ids := channelIDs(t, srv, memberCookie, guildID); containsID(ids, channelID) {
|
||||
t.Fatalf("channel must be hidden again after removing the role overwrite: %v", ids)
|
||||
}
|
||||
|
||||
// Снятие запрета @everyone делает комнату обычной.
|
||||
if rec := doJSON(t, srv, http.MethodDelete, basePath+"role/"+everyoneID, "", ownerCookie); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete everyone overwrite = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) {
|
||||
t.Fatalf("channel must be visible after clearing overwrites: %v", ids)
|
||||
}
|
||||
|
||||
// Аудит: изменения прав комнаты записаны.
|
||||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log", "", ownerCookie)
|
||||
entries := decodeResponse[struct {
|
||||
Entries []struct {
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
} `json:"entries"`
|
||||
}](t, audit)
|
||||
if !hasAction(entries.Entries, "channel.overwrite_set") || !hasAction(entries.Entries, "channel.overwrite_delete") {
|
||||
t.Fatalf("audit has no overwrite entries: %+v", entries.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelOverwriteTargetValidation проверяет отказ на чужую цель.
|
||||
func TestChannelOverwriteTargetValidation(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "target_owner", "target-owner@example.com")
|
||||
registerAndLogin(t, srv, "target_outsider", "target-outsider@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Цели"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
channelRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"общий","type":"text"}`, ownerCookie)
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, channelRec)
|
||||
|
||||
outsider, err := srv.auth.UserByEmail(t.Context(), "target-outsider@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
base := "/api/v1/guilds/" + guild.Guild.ID + "/channels/" + channel.Channel.ID + "/overwrites/"
|
||||
|
||||
// Пользователь не состоит в сервере.
|
||||
notMember := doJSON(t, srv, http.MethodPut,
|
||||
base+"user/"+formatSnowflake(outsider.ID),
|
||||
`{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie)
|
||||
if notMember.Code != http.StatusNotFound {
|
||||
t.Fatalf("overwrite for non-member = %d, want 404, body = %s", notMember.Code, notMember.Body.String())
|
||||
}
|
||||
|
||||
// Роль из другого сервера.
|
||||
otherGuild := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Другой"}`, ownerCookie)
|
||||
other := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, otherGuild)
|
||||
otherRole := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+other.Guild.ID+"/roles",
|
||||
`{"name":"Чужая"}`, ownerCookie)
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"role"`
|
||||
}](t, otherRole)
|
||||
foreign := doJSON(t, srv, http.MethodPut, base+"role/"+role.Role.ID,
|
||||
`{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie)
|
||||
if foreign.Code != http.StatusNotFound {
|
||||
t.Fatalf("overwrite for foreign role = %d, want 404, body = %s", foreign.Code, foreign.Body.String())
|
||||
}
|
||||
|
||||
// Неизвестный тип цели — 422 (huma отклоняет значение вне enum).
|
||||
badType := doJSON(t, srv, http.MethodPut, base+"everyone/123",
|
||||
`{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie)
|
||||
if badType.Code != http.StatusUnprocessableEntity && badType.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overwrite with bad target type = %d, want 422, body = %s", badType.Code, badType.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,50 @@ func (s *Store) SetChannelOverride(ctx context.Context, override ChannelOverride
|
||||
return err
|
||||
}
|
||||
|
||||
// ListGuildChannelOverrides отдаёт оверрайды всех комнат сервера одним
|
||||
// запросом: список комнат в API не должен превращаться в N+1 (AGENT.md 6.2).
|
||||
func (s *Store) ListGuildChannelOverrides(ctx context.Context, guildID uint64) (map[uint64][]ChannelOverride, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT o.channel_id, o.target_type, o.target_id, o.allow, o.deny
|
||||
FROM channel_overrides o
|
||||
JOIN channels c ON c.id = o.channel_id
|
||||
WHERE c.guild_id = ?`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[uint64][]ChannelOverride)
|
||||
for rows.Next() {
|
||||
var (
|
||||
override ChannelOverride
|
||||
allow, deny int64
|
||||
)
|
||||
if err := rows.Scan(&override.ChannelID, &override.TargetType, &override.TargetID, &allow, &deny); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
override.Allow = intToID(allow)
|
||||
override.Deny = intToID(deny)
|
||||
result[override.ChannelID] = append(result[override.ChannelID], override)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteChannelOverride снимает оверрайд: признак «был ли он» нужен аудиту.
|
||||
func (s *Store) DeleteChannelOverride(ctx context.Context, channelID uint64, targetType string, targetID uint64) (bool, error) {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM channel_overrides WHERE channel_id = ? AND target_type = ? AND target_id = ?`,
|
||||
int64(channelID), targetType, int64(targetID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]ChannelOverride, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT channel_id, target_type, target_id, allow, deny
|
||||
|
||||
Reference in New Issue
Block a user