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
+2 -2
View File
@@ -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})
}
+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
}
+265
View File
@@ -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())
}
}
+44
View File
@@ -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