From 785af0dc7b31c58391d90ed11b73def0f401871a Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 19:14:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(guilds):=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: - 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). --- internal/server/api_cosmetics.go | 4 +- internal/server/api_guilds.go | 217 +++++++++++- internal/server/channel_overwrite_test.go | 265 +++++++++++++++ internal/store/channels.go | 44 +++ web/e2e/support.ts | 300 +++++++++++++++++ web/e2e/voice.spec.ts | 231 +++---------- web/src/api/gatewayEvents.ts | 43 ++- web/src/api/guilds.ts | 39 +++ web/src/api/types.ts | 10 + web/src/i18n/locales/en.json | 14 +- web/src/i18n/locales/ru.json | 14 +- .../settings/ChannelPermissionsSection.tsx | 316 ++++++++++++++++++ .../pages/settings/ChannelSettingsPage.tsx | 53 ++- web/src/stores/gateway.ts | 10 + web/src/stores/session.ts | 12 + web/tests/channelPermissions.test.tsx | 216 ++++++++++++ 16 files changed, 1569 insertions(+), 219 deletions(-) create mode 100644 internal/server/channel_overwrite_test.go create mode 100644 web/e2e/support.ts create mode 100644 web/src/pages/settings/ChannelPermissionsSection.tsx create mode 100644 web/tests/channelPermissions.test.tsx diff --git a/internal/server/api_cosmetics.go b/internal/server/api_cosmetics.go index 39f4072..06fa142 100644 --- a/internal/server/api_cosmetics.go +++ b/internal/server/api_cosmetics.go @@ -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}) } diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index e493651..e0eba40 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -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 } diff --git a/internal/server/channel_overwrite_test.go b/internal/server/channel_overwrite_test.go new file mode 100644 index 0000000..cf79499 --- /dev/null +++ b/internal/server/channel_overwrite_test.go @@ -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()) + } +} diff --git a/internal/store/channels.go b/internal/store/channels.go index 89f210d..bf9c15c 100644 --- a/internal/store/channels.go +++ b/internal/store/channels.go @@ -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 diff --git a/web/e2e/support.ts b/web/e2e/support.ts new file mode 100644 index 0000000..8d37b5b --- /dev/null +++ b/web/e2e/support.ts @@ -0,0 +1,300 @@ +import { createHmac } from 'node:crypto'; +import { deflateSync } from 'node:zlib'; +import { + expect, + type APIRequestContext, + type Browser, + type BrowserContext, + type Page, +} from '@playwright/test'; + +/** + * support.ts — общие помощники живых e2e против развёрнутого инстанса + * (AGENT.md 11.2, 11.6): вход по 2FA, повтор при лимите частоты, постоянные + * тестовые аккаунты, подготовка сервера и генерация картинок для загрузок. + */ + +/** Значения окружения: без них живые тесты пропускаются. */ +export function adminCredentials(): { email: string; password: string; totp: string } { + return { + email: process.env.GLCHAT_ADMIN_EMAIL ?? '', + password: process.env.GLCHAT_ADMIN_PASSWORD ?? '', + totp: process.env.GLCHAT_ADMIN_TOTP ?? '', + }; +} + +/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */ +export function totp(secret: string, now = Date.now()): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = ''; + for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) { + const index = alphabet.indexOf(symbol); + if (index < 0) { + continue; + } + bits += index.toString(2).padStart(5, '0'); + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(Number.parseInt(bits.slice(i, i + 8), 2)); + } + const counter = Math.floor(now / 1000 / 30); + const buffer = Buffer.alloc(8); + buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0); + buffer.writeUInt32BE(counter % 2 ** 32, 4); + const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest(); + const offset = digest.readUInt8(digest.length - 1) & 0x0f; + const value = + ((digest.readUInt8(offset) & 0x7f) << 24) | + (digest.readUInt8(offset + 1) << 16) | + (digest.readUInt8(offset + 2) << 8) | + digest.readUInt8(offset + 3); + return (value % 1_000_000).toString().padStart(6, '0'); +} + +/** + * postWithRetry повторяет запрос при срабатывании лимита частоты: сервер + * отвечает `rate_limited` и `retry_after_ms`, а тест не должен падать из-за + * защиты, которая как раз и обязана работать (AGENT.md 9.4). + */ +export async function postWithRetry( + api: APIRequestContext, + path: string, + data: Record, + attempts = 6, +): Promise>> { + let response = await api.post(path, { data }); + for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { + const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; + const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000); + await new Promise((resolve) => setTimeout(resolve, wait + 200)); + response = await api.post(path, { data }); + } + return response; +} + +/** + * finishOnboarding помечает первичную настройку пройденной: иначе AuthGuard + * уводит браузер с `/app` на `/onboarding`. + */ +export async function finishOnboarding(api: APIRequestContext): Promise { + const me = await api.get('/api/v1/users/@me'); + expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy(); + const { user } = (await me.json()) as { + user: { onboarding_completed: boolean; username: string; display_name?: string | null }; + }; + if (user.onboarding_completed) { + return; + } + const done = await api.post('/api/v1/users/@me/onboarding/complete', { + data: { display_name: user.display_name ?? user.username }, + }); + expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy(); +} + +/** loginAsAdmin логинит api-контекст под инстанс-администратором. */ +export async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> { + const admin = adminCredentials(); + const response = await postWithRetry(api, '/api/v1/auth/login', { + email: admin.email, + password: admin.password, + totp_code: totp(admin.totp), + }); + expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy(); + const payload = (await response.json()) as { user: { id: string } }; + return payload.user; +} + +export interface Probe { + id: string; + username: string; + email: string; + password: string; +} + +/** + * ensureProbe логинит постоянный тестовый аккаунт, а если его ещё нет — + * регистрирует. Один и тот же аккаунт переиспользуется между прогонами: иначе + * каждый запуск оставлял бы в базе нового пользователя. + */ +export async function ensureProbe( + api: APIRequestContext, + username: string, + password: string, +): Promise { + const email = `${username}@gl.mhspx.su`; + const credentials = { email, password }; + let login = await postWithRetry(api, '/api/v1/auth/login', credentials); + if (!login.ok() && login.status() !== 429) { + const registered = await postWithRetry(api, '/api/v1/auth/register', { + username, + ...credentials, + }); + // 409 — аккаунт уже существует (например, гонка прогонов): просто входим. + expect( + registered.ok() || registered.status() === 409, + `тестовый пользователь ${username} не зарегистрирован: ${await registered.text()}`, + ).toBeTruthy(); + login = await postWithRetry(api, '/api/v1/auth/login', credentials); + } + expect(login.ok(), `вход тестового пользователя ${username}: ${await login.text()}`).toBeTruthy(); + const payload = (await login.json()) as { user: { id: string } }; + await finishOnboarding(api); + return { id: payload.user.id, username, email, password }; +} + +export interface SignInOptions { + /** Параметры контекста: разрешения, viewport и прочее. */ + contextOptions?: Parameters[0]; + /** Хук до создания страницы: сюда вешают init-скрипты (например, патч WebRTC). */ + onContext?: (context: BrowserContext) => Promise; +} + +/** signIn логинит браузерный контекст и открывает приложение. */ +export async function signIn( + browser: Browser, + options: { email: string; password: string; totp?: string }, + extra: SignInOptions = {}, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext(extra.contextOptions ?? {}); + if (extra.onContext !== undefined) { + await extra.onContext(context); + } + const page = await context.newPage(); + await page.goto('/login'); + await page.getByLabel('Почта', { exact: true }).fill(options.email); + await page.getByLabel('Пароль', { exact: true }).fill(options.password); + // Лимит частоты входов может сработать на серии прогонов: тогда форма + // показывает ошибку — ждём и пробуем снова со свежим кодом 2FA. + for (let attempt = 1; attempt <= 4; attempt += 1) { + if (options.totp !== undefined) { + await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp)); + } + await page.getByRole('button', { name: /Войти/u }).click(); + try { + await expect(page).toHaveURL(/\/app/u, { timeout: 25_000 }); + return { context, page }; + } catch (error) { + if (attempt === 4) { + throw error; + } + await page.waitForTimeout(8_000); + } + } + throw new Error('вход не удался'); +} + +/** + * dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал + * до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени. + */ +export async function dropStaleGuilds(api: APIRequestContext, prefix: string): Promise { + const response = await api.get('/api/v1/users/@me/guilds'); + if (!response.ok()) { + return; + } + const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] }; + for (const guild of guilds) { + if (guild.name.startsWith(prefix)) { + await api.delete(`/api/v1/guilds/${guild.id}`); + } + } +} + +/** createGuild создаёт сервер от имени владельца и возвращает его id. */ +export async function createGuild( + api: APIRequestContext, + name: string, +): Promise<{ id: string; name: string }> { + const response = await api.post('/api/v1/guilds', { data: { name } }); + expect(response.ok(), `создание сервера: ${await response.text()}`).toBeTruthy(); + const payload = (await response.json()) as { guild: { id: string } }; + return { id: payload.guild.id, name }; +} + +/** createInvite создаёт приглашение в сервер и возвращает код. */ +export async function createInvite(api: APIRequestContext, guildId: string): Promise { + const response = await api.post(`/api/v1/guilds/${guildId}/invites`, { + data: { max_uses: 0, max_age_seconds: 3600 }, + }); + expect(response.ok(), `создание приглашения: ${await response.text()}`).toBeTruthy(); + const payload = (await response.json()) as { invite: { code: string } }; + return payload.invite.code; +} + +/** joinGuild вступает в сервер по коду приглашения. */ +export async function joinGuild(api: APIRequestContext, code: string): Promise { + const response = await postWithRetry(api, `/api/v1/invites/${code}`, {}); + expect(response.ok(), `принятие приглашения: ${await response.text()}`).toBeTruthy(); +} + +/** + * trackLoads считает полные загрузки документа: SPA-переходы их не создают, + * поэтому нулевой счётчик после подготовки доказывает, что ни один шаг + * матрицы 11.6 не потребовал F5. + */ +export function trackLoads(page: Page): { count: () => number; reset: () => void } { + let loads = 0; + page.on('load', () => { + loads += 1; + }); + return { count: () => loads, reset: () => (loads = 0) }; +} + +/** openGuild открывает сервер в сайдбаре. */ +export async function openGuild(page: Page, guildName: string): Promise { + await page.getByRole('link', { name: `Открыть сервер ${guildName}`, exact: true }).click(); +} + +/** openChannel открывает комнату сервера по имени. */ +export async function openChannel(page: Page, channelName: string): Promise { + await page.getByRole('link', { name: new RegExp(`Открыть комнату ${channelName}`, 'u') }).click(); +} + +/** + * pngBytes собирает PNG заданного размера и цвета: сервер проверяет реальный + * формат картинки, поэтому подделкой заголовков не обойтись. + */ +export function pngBytes(width: number, height: number, rgb: [number, number, number]): Buffer { + const raw = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y += 1) { + const rowStart = y * (width * 3 + 1); + raw[rowStart] = 0; + for (let x = 0; x < width; x += 1) { + const at = rowStart + 1 + x * 3; + raw[at] = rgb[0]; + raw[at + 1] = rgb[1]; + raw[at + 2] = rgb[2]; + } + } + const chunk = (type: string, data: Buffer): Buffer => { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([length, body, crc]); + }; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/web/e2e/voice.spec.ts b/web/e2e/voice.spec.ts index 31197cd..0d8326c 100644 --- a/web/e2e/voice.spec.ts +++ b/web/e2e/voice.spec.ts @@ -1,4 +1,3 @@ -import { createHmac } from 'node:crypto'; import { expect, test, @@ -8,6 +7,15 @@ import { type Page, } from '@playwright/test'; +import { + adminCredentials, + dropStaleGuilds, + ensureProbe, + finishOnboarding, + loginAsAdmin, + signIn, +} from './support'; + /** * Голосовой e2e: два реальных браузера входят в одну голосовую комнату и * проверяется, что аудио действительно доходит через LiveKit (AGENT.md 11.2, @@ -19,40 +27,9 @@ import { * npx playwright test */ const enabled = process.env.GLCHAT_E2E_VOICE === '1'; -const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? ''; -const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? ''; -const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? ''; /** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */ const PROBE_LOGIN = 'voiceprobe'; - -/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */ -function totp(secret: string, now = Date.now()): string { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - let bits = ''; - for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) { - const index = alphabet.indexOf(symbol); - if (index < 0) { - continue; - } - bits += index.toString(2).padStart(5, '0'); - } - const bytes: number[] = []; - for (let i = 0; i + 8 <= bits.length; i += 8) { - bytes.push(Number.parseInt(bits.slice(i, i + 8), 2)); - } - const counter = Math.floor(now / 1000 / 30); - const buffer = Buffer.alloc(8); - buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0); - buffer.writeUInt32BE(counter % 2 ** 32, 4); - const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest(); - const offset = digest.readUInt8(digest.length - 1) & 0x0f; - const value = - ((digest.readUInt8(offset) & 0x7f) << 24) | - (digest.readUInt8(offset + 1) << 16) | - (digest.readUInt8(offset + 2) << 8) | - digest.readUInt8(offset + 3); - return (value % 1_000_000).toString().padStart(6, '0'); -} +const admin = adminCredentials(); interface Fixture { guildId: string; @@ -64,148 +41,35 @@ interface Fixture { guestId: string; } -/** - * dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал - * до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени. - */ -async function dropStaleGuilds(api: APIRequestContext): Promise { - const response = await api.get('/api/v1/users/@me/guilds'); - if (!response.ok()) { - return; - } - const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] }; - for (const guild of guilds) { - if (guild.name.startsWith('Voice E2E ')) { - await api.delete(`/api/v1/guilds/${guild.id}`); - } - } -} - -/** - * finishOnboarding помечает первичную настройку пройденной, если она ещё не - * завершена: иначе AuthGuard уводит браузер с `/app` на `/onboarding` и тест - * не доходит до голосовой комнаты. Пустое тело запроса ничего не перезаписывает. - */ -async function finishOnboarding(api: APIRequestContext): Promise { - const me = await api.get('/api/v1/users/@me'); - expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy(); - const { user } = (await me.json()) as { - user: { onboarding_completed: boolean; username: string; display_name?: string | null }; - }; - if (user.onboarding_completed) { - return; - } - const done = await api.post('/api/v1/users/@me/onboarding/complete', { - data: { display_name: user.display_name ?? user.username }, - }); - expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy(); -} - -/** - * loginAsAdmin логинит api-контекст под инстанс-администратором и возвращает - * его профиль. Код 2FA одноразовый в пределах окна, поэтому администратор - * логинится ровно один раз — отдельным контекстом, который гостевые действия - * не переключают на другого пользователя. - */ -async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> { - const response = await postWithRetry(api, '/api/v1/auth/login', { - email: ADMIN_EMAIL, - password: ADMIN_PASSWORD, - totp_code: totp(ADMIN_TOTP), - }); - expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy(); - const payload = (await response.json()) as { user: { id: string } }; - return payload.user; -} - -/** - * postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает - * `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты, - * которая как раз и обязана работать (AGENT.md 9.4). - */ -async function postWithRetry( - api: APIRequestContext, - path: string, - data: Record, - attempts = 6, -): Promise>> { - let response = await api.post(path, { data }); - for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { - const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; - const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 15_000); - await new Promise((resolve) => setTimeout(resolve, wait + 200)); - response = await api.post(path, { data }); - } - return response; -} - -/** - * ensureProbeGuest логинит постоянного тестового пользователя, а если его ещё - * нет — регистрирует. Один и тот же аккаунт переиспользуется между прогонами: - * иначе каждый запуск оставлял бы в базе нового пользователя, а удалять - * пользователей API пока не умеет (это задача админ-панели Фазы 5). - * - * Важно различать «пользователя нет» (401 — тогда регистрируем) и «сработал - * лимит частоты» (429 — ждём и повторяем, иначе тест падал бы из-за защиты, - * которая обязана работать). - */ -async function ensureProbeGuest( - guest: APIRequestContext, -): Promise<{ id: string; email: string; password: string }> { - const email = `${PROBE_LOGIN}@gl.mhspx.su`; - const password = 'Voice-Probe-Password-9x'; - const credentials = { email, password }; - let login = await postWithRetry(guest, '/api/v1/auth/login', credentials); - if (login.status() === 429) { - throw new Error(`вход тестового пользователя упёрся в лимит частоты: ${await login.text()}`); - } - if (!login.ok()) { - const registered = await postWithRetry(guest, '/api/v1/auth/register', { - username: PROBE_LOGIN, - ...credentials, - }); - expect( - registered.ok() || registered.status() === 409, - `тестовый пользователь ${PROBE_LOGIN} не зарегистрирован: ${await registered.text()}`, - ).toBeTruthy(); - // 409 — аккаунт уже существует (например, гонка прогонов): просто входим. - login = await postWithRetry(guest, '/api/v1/auth/login', credentials); - } - expect(login.ok(), `вход тестового пользователя: ${await login.text()}`).toBeTruthy(); - const payload = (await login.json()) as { user: { id: string } }; - await finishOnboarding(guest); - return { id: payload.user.id, email, password }; -} - /** * prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового * пользователя. `admin` остаётся сессией администратора (модерация и уборка), * `guest` — сессией приглашённого пользователя. */ -async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise { - const adminUser = await loginAsAdmin(admin); - await finishOnboarding(admin); - await dropStaleGuilds(admin); +async function prepare(adminApi: APIRequestContext, guest: APIRequestContext): Promise { + const adminUser = await loginAsAdmin(adminApi); + await finishOnboarding(adminApi); + await dropStaleGuilds(adminApi, 'Voice E2E '); const stamp = Date.now(); const guildName = `Voice E2E ${stamp}`; - const guild = await admin.post('/api/v1/guilds', { data: { name: guildName } }); + const guild = await adminApi.post('/api/v1/guilds', { data: { name: guildName } }); expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy(); const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id; - const channel = await admin.post(`/api/v1/guilds/${guildId}/channels`, { + const channel = await adminApi.post(`/api/v1/guilds/${guildId}/channels`, { data: { name: 'Голос', type: 'voice' }, }); expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy(); const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id; - const invite = await admin.post(`/api/v1/guilds/${guildId}/invites`, { + const invite = await adminApi.post(`/api/v1/guilds/${guildId}/invites`, { data: { max_uses: 1, max_age_seconds: 600 }, }); expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy(); const code = ((await invite.json()) as { invite: { code: string } }).invite.code; - const probe = await ensureProbeGuest(guest); + const probe = await ensureProbe(guest, PROBE_LOGIN, 'Voice-Probe-Password-9x'); const accept = await guest.post(`/api/v1/invites/${code}`); expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy(); @@ -256,35 +120,18 @@ async function inboundAudioBytes(page: Page): Promise { }); } -/** signIn логинит браузерный контекст и открывает приложение. */ -async function signIn( +/** + * signInVoice логинит браузерный контекст с фейковыми медиа-устройствами и + * патчем RTCPeerConnection: так проверяется реальная доставка аудио. + */ +async function signInVoice( browser: Browser, options: { email: string; password: string; totp?: string }, ): Promise<{ context: BrowserContext; page: Page }> { - const context = await browser.newContext({ permissions: ['microphone'] }); - await instrumentPeerConnections(context); - const page = await context.newPage(); - await page.goto('/login'); - await page.getByLabel('Почта', { exact: true }).fill(options.email); - await page.getByLabel('Пароль', { exact: true }).fill(options.password); - // Лимит частоты входов (5/мин) может сработать на серии прогонов: тогда - // форма показывает ошибку — ждём и пробуем снова со свежим кодом 2FA. - for (let attempt = 1; attempt <= 3; attempt += 1) { - if (options.totp !== undefined) { - await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp)); - } - await page.getByRole('button', { name: /Войти/u }).click(); - try { - await expect(page).toHaveURL(/\/app/u, { timeout: 20_000 }); - return { context, page }; - } catch (error) { - if (attempt === 3) { - throw error; - } - await page.waitForTimeout(6_000); - } - } - throw new Error('вход не удался'); + return signIn(browser, options, { + contextOptions: { permissions: ['microphone'] }, + onContext: instrumentPeerConnections, + }); } /** @@ -302,28 +149,28 @@ async function joinVoice(page: Page): Promise { test.describe('голос: два клиента в одной комнате', () => { test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1'); - test.skip(ADMIN_EMAIL === '' || ADMIN_PASSWORD === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP'); + test.skip(admin.email === '' || admin.password === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP'); test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => { const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; const adminApi = await playwright.request.newContext({ baseURL }); const guestApi = await playwright.request.newContext({ baseURL }); const fixture = await prepare(adminApi, guestApi); - let admin: Awaited> | null = null; - let guest: Awaited> | null = null; + let adminBrowser: Awaited> | null = null; + let guestBrowser: Awaited> | null = null; try { - admin = await signIn(browser, { - email: ADMIN_EMAIL, - password: ADMIN_PASSWORD, - totp: ADMIN_TOTP, + adminBrowser = await signInVoice(browser, { + email: admin.email, + password: admin.password, + totp: admin.totp, }); - guest = await signIn(browser, { + guestBrowser = await signInVoice(browser, { email: fixture.guestEmail, password: fixture.guestPassword, }); - const adminSession = admin; - const guestSession = guest; + const adminSession = adminBrowser; + const guestSession = guestBrowser; // Оба клиента открывают голосовую комнату и подключаются. for (const session of [adminSession, guestSession]) { @@ -398,8 +245,8 @@ test.describe('голос: два клиента в одной комнате', } finally { // Уборка: тестовый сервер удаляется вместе с комнатами и состояниями. await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`); - await admin?.context.close(); - await guest?.context.close(); + await adminBrowser?.context.close(); + await guestBrowser?.context.close(); await adminApi.dispose(); await guestApi.dispose(); } diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 7c21f73..dd31bf8 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -1,6 +1,6 @@ import { parseGuildEmojiList } from './emojis'; import { parseGuildSoundList } from './sounds'; -import { userStatuses, type Channel, type UserStatus } from './types'; +import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types'; import type { GatewayUser } from './gateway'; import type { GuildEmoji } from './emojis'; import type { GuildSound } from './sounds'; @@ -284,6 +284,42 @@ export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null } /** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */ +/** `GUILD_CHANNELS_SYNC`: права комнат сервера изменились — перечитать список. */ +export function parseGuildChannelsSyncEvent(value: unknown): { guild_id: string } | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const guildId = asString(record['guild_id']); + return guildId === undefined ? null : { guild_id: guildId }; +} + +/** parseChannelOverwrites читает список оверрайдов прав комнаты. */ +export function parseChannelOverwrites(value: unknown): ChannelOverwrite[] { + if (!Array.isArray(value)) { + return []; + } + const result: ChannelOverwrite[] = []; + for (const item of value) { + const record = asRecord(item); + if (record === null) { + continue; + } + const targetType = asString(record['target_type']); + const targetId = asString(record['target_id']); + if ((targetType !== 'role' && targetType !== 'user') || targetId === undefined) { + continue; + } + result.push({ + target_type: targetType, + target_id: targetId, + allow: asString(record['allow']) ?? '0', + deny: asString(record['deny']) ?? '0', + }); + } + return result; +} + export function parseChannelPayload(value: unknown): Channel | null { const record = asRecord(value); if (record === null) { @@ -327,6 +363,11 @@ export function parseChannelPayload(value: unknown): Channel | null { if (typeof record['can_view'] === 'boolean') { channel.can_view = record['can_view']; } + // Права комнаты: нужны редактору доступа (AGENT.md 6.2). + const overwrites = parseChannelOverwrites(record['permission_overwrites']); + if (overwrites.length > 0) { + channel.permission_overwrites = overwrites; + } // Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5). const backgroundFileId = asString(record['background_file_id']); if (backgroundFileId !== undefined) { diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index a564258..b7d0bac 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -120,6 +120,45 @@ export async function updateRole( return payload.role; } +/** Права комнаты в масках (AGENT.md 6.2): тем же способом, что права роли. */ +export interface ChannelOverwriteInput { + allow: string; + deny: string; + step_up_password?: string; + step_up_totp?: string; +} + +/** + * `PUT /guilds/{id}/channels/{cid}/overwrites/{type}/{tid}` — права комнаты + * для роли или участника поверх серверных. + */ +export async function setChannelOverwrite( + guildId: string, + channelId: string, + targetType: 'role' | 'user', + targetId: string, + body: ChannelOverwriteInput, +): Promise { + const payload = await request<{ channel: Channel }>( + `/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`, + { method: 'PUT', body }, + ); + return payload.channel; +} + +/** `DELETE .../overwrites/{type}/{tid}` — снять права комнаты (нужен step-up). */ +export function deleteChannelOverwrite( + guildId: string, + channelId: string, + targetType: 'role' | 'user', + targetId: string, +): Promise<{ ok: true }> { + return request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`, + { method: 'DELETE' }, + ); +} + export function joinGuild(guildId: string): Promise<{ ok: true }> { return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 61cb652..d72fb48 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -75,6 +75,14 @@ export interface Role { nick_effect?: string; } +/** Оверрайд прав комнаты для роли или участника (AGENT.md 6.2). */ +export interface ChannelOverwrite { + target_type: 'role' | 'user'; + target_id: string; + allow: string; + deny: string; +} + export interface Channel { id: string; /** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */ @@ -89,6 +97,8 @@ export interface Channel { can_view?: boolean; can_send?: boolean; can_connect?: boolean; + /** Права комнаты поверх серверных (AGENT.md 6.2): маски приходят строками. */ + permission_overwrites?: ChannelOverwrite[]; /** * Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый * статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8a8ea83..96b8104 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1182,7 +1182,19 @@ "upload": "Upload background", "remove": "Remove background", "notImage": "An image file is required", - "tooLarge": "File is larger than 5 MB" + "tooLarge": "File is larger than 5 MB", + "permissions": { + "title": "Channel access", + "description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.", + "private": "Private channel (hide from @everyone)", + "everyone": "@everyone", + "view": "View", + "send": "Send", + "connect": "Connect", + "inherit": "Server default", + "allow": "Allow", + "deny": "Deny" + } } }, "badges": { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1253316..477926c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1182,7 +1182,19 @@ "upload": "Загрузить фон", "remove": "Убрать фон", "notImage": "Нужен файл изображения", - "tooLarge": "Файл больше 5 МБ" + "tooLarge": "Файл больше 5 МБ", + "permissions": { + "title": "Доступ к комнате", + "description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.", + "private": "Приватная комната (скрыть от @everyone)", + "everyone": "@everyone", + "view": "Просмотр", + "send": "Писать", + "connect": "Вход", + "inherit": "Как на сервере", + "allow": "Разрешено", + "deny": "Запрещено" + } } }, "badges": { diff --git a/web/src/pages/settings/ChannelPermissionsSection.tsx b/web/src/pages/settings/ChannelPermissionsSection.tsx new file mode 100644 index 0000000..4e0a9b1 --- /dev/null +++ b/web/src/pages/settings/ChannelPermissionsSection.tsx @@ -0,0 +1,316 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { + deleteChannelOverwrite, + fetchMembers, + fetchRoles, + guildChannelsQueryKey, + guildMembersQueryKey, + guildRolesQueryKey, + setChannelOverwrite, +} from '@/api/guilds'; +import type { Channel, ChannelOverwrite, GuildMember, Role } from '@/api/types'; +import { stepUp } from '@/api/auth'; +import { Button, Card } from '@/components/ui/primitives'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Field } from '@/components/ui/Field'; +import { errorCode } from '@/lib/format'; +import { useCurrentUser } from '@/lib/hooks'; + +/** Имена прав, которыми управляет редактор доступа (AGENT.md 6.2). */ +const VIEW_CHANNEL = 'VIEW_CHANNEL'; +const SEND_MESSAGES = 'SEND_MESSAGES'; +const CONNECT_VOICE = 'CONNECT_VOICE'; + +/** Три состояния права: как на сервере, разрешено, запрещено. */ +type TriState = 'inherit' | 'allow' | 'deny'; + +/** Действие над оверрайдом: задать права или снять цель целиком. */ +type PendingAction = + | { kind: 'set'; targetType: 'role' | 'user'; targetId: string; allow: string; deny: string } + | { kind: 'delete'; targetType: 'role' | 'user'; targetId: string }; + +/** names разбирает строку прав «A|B» в список, отбрасывая пустые значения. */ +function names(value: string | undefined): string[] { + return (value ?? '').split('|').filter((item) => item !== ''); +} + +interface ChannelPermissionsSectionProps { + guildId: string | null; + channel: Channel; +} + +/** stateOf читает состояние одного права из оверрайда. */ +function stateOf(overwrite: ChannelOverwrite | undefined, name: string): TriState { + if (overwrite === undefined) { + return 'inherit'; + } + if (names(overwrite.allow).includes(name)) { + return 'allow'; + } + if (names(overwrite.deny).includes(name)) { + return 'deny'; + } + return 'inherit'; +} + +/** applyState меняет одно право, сохраняя остальные (AGENT.md 6.2). */ +function applyState( + overwrite: ChannelOverwrite | undefined, + name: string, + state: TriState, +): { allow: string; deny: string } { + const allow = names(overwrite?.allow).filter((item) => item !== name); + const deny = names(overwrite?.deny).filter((item) => item !== name); + if (state === 'allow') { + allow.push(name); + } + if (state === 'deny') { + deny.push(name); + } + return { allow: allow.join('|'), deny: deny.join('|') }; +} + +/** + * Редактор доступа к комнате (AGENT.md 6.2, 7.5): приватность для @everyone + * плюс права ролей и участников по каждому из ключевых прав. Изменение прав + * требует подтверждения личности (AGENT.md 9.3) — сервер отвечает + * `auth.step_up_required`, после чего действие повторяется со step-up. + */ +export function ChannelPermissionsSection({ guildId, channel }: ChannelPermissionsSectionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const currentUser = useCurrentUser(); + const [actionError, setActionError] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const [stepUpPassword, setStepUpPassword] = useState(''); + const [stepUpCode, setStepUpCode] = useState(''); + + const roles = useQuery({ + queryKey: guildRolesQueryKey(guildId ?? ''), + queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal), + enabled: guildId !== null, + retry: 0, + }); + const members = useQuery({ + queryKey: guildMembersQueryKey(guildId ?? ''), + queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal), + enabled: guildId !== null, + retry: 0, + }); + + const overwrites = useMemo(() => channel.permission_overwrites ?? [], [channel]); + const defaultRole = useMemo( + () => (roles.data ?? []).find((role) => role.is_default) ?? null, + [roles.data], + ); + const overwriteFor = ( + targetType: 'role' | 'user', + targetId: string, + ): ChannelOverwrite | undefined => + overwrites.find((item) => item.target_type === targetType && item.target_id === targetId); + + const isPrivate = + stateOf( + defaultRole === null ? undefined : overwriteFor('role', defaultRole.id), + VIEW_CHANNEL, + ) === 'deny'; + + const invalidate = (): void => { + void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') }); + }; + + const action = useMutation({ + mutationFn: async (input: PendingAction) => { + if (guildId === null) { + return; + } + if (input.kind === 'set') { + await setChannelOverwrite(guildId, channel.id, input.targetType, input.targetId, { + allow: input.allow, + deny: input.deny, + }); + return; + } + await deleteChannelOverwrite(guildId, channel.id, input.targetType, input.targetId); + }, + onSuccess: () => { + setActionError(null); + setPendingAction(null); + setStepUpPassword(''); + setStepUpCode(''); + invalidate(); + }, + onError: (cause: unknown, variables) => { + setActionError(cause); + // Сервер требует свежее подтверждение личности (AGENT.md 9.3): показываем + // форму и повторяем то же действие после успешного step-up. + if (errorCode(cause) === 'auth.step_up_required') { + setPendingAction(variables); + } + }, + }); + + const confirmIdentity = useMutation({ + mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode), + onSuccess: () => { + const retry = pendingAction; + setStepUpPassword(''); + setStepUpCode(''); + if (retry !== null) { + action.mutate(retry); + } + }, + }); + + if (guildId === null) { + return null; + } + + const permissions: { name: string; label: string }[] = + channel.type === 'voice' + ? [ + { name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') }, + { name: CONNECT_VOICE, label: t('settings.channel.permissions.connect') }, + ] + : [ + { name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') }, + { name: SEND_MESSAGES, label: t('settings.channel.permissions.send') }, + ]; + + const rows: { key: string; title: string; targetType: 'role' | 'user'; targetId: string }[] = [ + ...(roles.data ?? []).map((role: Role) => ({ + key: `role-${role.id}`, + title: role.is_default ? t('settings.channel.permissions.everyone') : role.name, + targetType: 'role' as const, + targetId: role.id, + })), + ...(members.data ?? []).map((member: GuildMember) => ({ + key: `user-${member.user_id}`, + title: `${member.display_name} @${member.username ?? ''}`, + targetType: 'user' as const, + targetId: member.user_id, + })), + ]; + + const togglePrivate = (next: boolean): void => { + if (defaultRole === null) { + return; + } + const existing = overwriteFor('role', defaultRole.id); + if (!next) { + // Снимаем оверрайд @everyone целиком: комната снова как на сервере. + action.mutate({ kind: 'delete', targetType: 'role', targetId: defaultRole.id }); + return; + } + const masks = applyState(existing, VIEW_CHANNEL, 'deny'); + action.mutate({ kind: 'set', targetType: 'role', targetId: defaultRole.id, ...masks }); + }; + + const pending = action.isPending || confirmIdentity.isPending; + + return ( + +

{t('settings.channel.permissions.title')}

+

{t('settings.channel.permissions.description')}

+ + {actionError === null ? null : } + + {pendingAction === null ? null : ( +
{ + event.preventDefault(); + confirmIdentity.mutate(); + }} + > + setStepUpPassword(event.target.value)} + required + /> + {currentUser.data?.totp_enabled === true ? ( + setStepUpCode(event.target.value)} + /> + ) : null} + {confirmIdentity.isError ? : null} + + + + )} + + + +
+ {rows.map((row) => { + const overwrite = overwriteFor(row.targetType, row.targetId); + const canEdit = !(row.targetType === 'user' && row.targetId === currentUser.data?.id); + return ( +
+ {row.title} + {permissions.map((permission) => ( + + ))} +
+ ); + })} +
+
+ ); +} diff --git a/web/src/pages/settings/ChannelSettingsPage.tsx b/web/src/pages/settings/ChannelSettingsPage.tsx index 117e9ce..9da3da9 100644 --- a/web/src/pages/settings/ChannelSettingsPage.tsx +++ b/web/src/pages/settings/ChannelSettingsPage.tsx @@ -13,6 +13,7 @@ import { canManageWebhooks } from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection'; +import { ChannelPermissionsSection } from '@/pages/settings/ChannelPermissionsSection'; import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection'; import { GuildPicker } from '@/pages/settings/GuildPicker'; @@ -80,21 +81,26 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) { retry: 0, }); - const textChannels = useMemo( - () => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'), + // В списке и текстовые, и голосовые комнаты: у голосовых настраиваются фон и + // права доступа (AGENT.md 7.5), вебхуки — только у текстовых. + const selectableChannels = useMemo( + () => + (channels.data ?? []).filter( + (channel: Channel) => channel.type === 'text' || channel.type === 'voice', + ), [channels.data], ); - // Первую текстовую комнату выбираем сами: список только что загрузился. + // Первую комнату выбираем сами: список только что загрузился. useEffect(() => { - if (settingsChannelId !== null || textChannels.length === 0) { + if (settingsChannelId !== null || selectableChannels.length === 0) { return; } - const first = textChannels[0]; + const first = selectableChannels[0]; if (first !== undefined) { selectSettingsChannel(first.id); } - }, [settingsChannelId, textChannels, selectSettingsChannel]); + }, [settingsChannelId, selectableChannels, selectSettingsChannel]); if (guildId === null || guild === null) { return ( @@ -111,13 +117,19 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) { currentUser.data?.id, isInstanceAdmin, ); + // Права комнаты меняет тот, кто управляет ролями (AGENT.md 6.2). + const canManagePermissions = + isInstanceAdmin || + guild.owner_id === currentUser.data?.id || + (guild.my_permissions ?? []).includes('ADMINISTRATOR') || + (guild.my_permissions ?? []).includes('MANAGE_ROLES'); // Фон комнаты — отдельное право MANAGE_CHANNEL_BACKGROUND (AGENT.md 7.5). const canManageBackground = isInstanceAdmin || guild.owner_id === currentUser.data?.id || (guild.my_permissions ?? []).includes('ADMINISTRATOR') || (guild.my_permissions ?? []).includes('MANAGE_CHANNEL_BACKGROUND'); - const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null; + const active = selectableChannels.find((channel) => channel.id === settingsChannelId) ?? null; return ( <> @@ -131,21 +143,21 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) { void channels.refetch()} /> ) : null} - {channels.isSuccess && textChannels.length === 0 ? ( + {channels.isSuccess && selectableChannels.length === 0 ? (

{t('settings.channel.noChannels')}

) : null} - {textChannels.length === 0 ? null : ( + {selectableChannels.length === 0 ? null : ( selectSettingsChannel(value)} - options={textChannels.map((channel) => ({ + options={selectableChannels.map((channel) => ({ value: channel.id, - label: `#${channel.name}`, + label: `${channel.type === 'voice' ? '🔊' : '#'}${channel.name}`, }))} /> @@ -159,11 +171,20 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) { backgroundFileId={active.background_file_id} canManage={canManageBackground} /> - + {active.type === 'text' ? ( + + ) : null} + {canManagePermissions ? ( + + ) : null} {canManage ? null : (

{t('settings.channel.noPermission')}

diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index 1b6bc13..0285451 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -11,6 +11,7 @@ import { } from '@/api/gateway'; import { parseChannelDelete, + parseGuildChannelsSyncEvent, parseChannelPayload, parseDmChannelEvent, parseGuildEmojisUpdate, @@ -334,6 +335,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { } return; } + case 'GUILD_CHANNELS_SYNC': { + // Права комнаты изменились: видимость может поменяться у всех + // участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6). + const sync = parseGuildChannelsSyncEvent(event.d); + if (sync !== null) { + void session.reloadGuildChannels(sync.guild_id); + } + return; + } case 'DM_CHANNEL_CREATE': { // В событии только channel_id: список бесед забираем по REST. const direct = parseDmChannelEvent(event.d); diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index d29fad8..ef3c1e3 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { GuildEmoji } from '@/api/emojis'; +import { fetchChannels } from '@/api/guilds'; import { fetchDirectChannels } from '@/api/friends'; import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway'; import type { GuildSound, SoundKind } from '@/api/sounds'; @@ -113,6 +114,8 @@ interface SessionState { reloadDmChannels: () => Promise; /** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */ removeChannel: (guildId: string, channelId: string) => void; + /** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */ + reloadGuildChannels: (guildId: string) => Promise; /** Первая доступная (видимая) комната сервера — для выбора после удалений. */ firstChannelId: (guildId: string) => string | null; selectGuild: (guildId: string | null) => void; @@ -351,6 +354,15 @@ export const useSessionStore = create((set, get) => ({ }); }, + reloadGuildChannels: async (guildId) => { + try { + const channels = await fetchChannels(guildId); + get().setGuildChannels(guildId, channels); + } catch { + // Сеть подвела: список обновится при следующем изменении прав. + } + }, + setGuildChannels: (guildId, channels) => { set({ guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)), diff --git a/web/tests/channelPermissions.test.tsx b/web/tests/channelPermissions.test.tsx new file mode 100644 index 0000000..d4668ae --- /dev/null +++ b/web/tests/channelPermissions.test.tsx @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + installFetch, + installGatewaySocket, + json, + makeReadySnapshot, + makeUser, + messagesRoutes, + renderApp, + resetStores, + type FetchRoute, +} from './helpers'; + +/** + * Доступ к комнате (AGENT.md 6.2, 7.5): приватная комната закрывается запретом + * @everyone, права выдаются ролям, а смена прав требует подтверждения личности. + */ + +const user = makeUser({ id: 'user-1' }); + +const channel = { + id: 'c-1', + guild_id: 'g-1', + name: 'общий', + type: 'text' as const, + position: 0, + can_view: true, + permission_overwrites: [], +}; + +const roles = [ + { id: 'r-1', name: '@everyone', color: 0, position: 0, permissions: '', is_default: true }, + { id: 'r-2', name: 'Модераторы', color: 0, position: 1, permissions: '', is_default: false }, +]; + +const members = [ + { + user_id: 'user-2', + username: 'bob', + display_name: 'Bob', + status: 'online', + is_instance_admin: false, + joined_at: '2026-09-01T00:00:00Z', + role_ids: [], + }, +]; + +/** routes собирает мок REST: комнаты, роли и участники сервера. */ +function routes(permissions: string[], extra: FetchRoute[] = []): FetchRoute[] { + return [ + ...extra, + { + match: '/api/v1/users/@me/guilds', + response: () => + json({ + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 2, + my_permissions: permissions, + my_role_ids: [], + }, + ], + }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) }, + { + match: '/api/v1/guilds/g-1', + response: () => + json({ + guild: { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 2, + my_role_ids: [], + my_permissions: permissions, + channels: [channel], + roles: [], + }, + }), + }, + ...messagesRoutes('c-1', []), + ]; +} + +beforeEach(() => { + resetStores(); +}); + +/** openChannelSettings открывает настройки комнаты из шапки чата. */ +async function openChannelSettings(permissions: string[], extra: FetchRoute[] = []) { + const fetchMock = installFetch(routes(permissions, extra)); + const snapshot = makeReadySnapshot([ + { + id: 'g-1', + name: 'Сервер', + is_main: true, + owner_id: permissions.includes('MANAGE_ROLES') ? 'user-1' : 'user-9', + my_permissions: permissions, + channels: [channel], + }, + ]); + const gateway = installGatewaySocket(snapshot); + renderApp('/app/g-1/c-1'); + await gateway.greet(); + await screen.findByTestId('message-list'); + await userEvent.click(await screen.findByTestId('channel-settings')); + const content = await screen.findByTestId('settings-content'); + return { fetchMock, content }; +} + +describe('доступ к комнате', () => { + it('приватная комната закрывается запретом @everyone, права ролей меняются', async () => { + const calls: { url: string; body: unknown }[] = []; + const { content } = await openChannelSettings( + ['MANAGE_ROLES', 'MANAGE_GUILD'], + [ + { + match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-1', + method: 'PUT', + response: (request) => { + calls.push({ url: request.url, body: request.body }); + return json({ channel }); + }, + }, + { + match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-2', + method: 'PUT', + response: (request) => { + calls.push({ url: request.url, body: request.body }); + return json({ channel }); + }, + }, + ], + ); + + const editor = await within(content).findByTestId('channel-permissions'); + expect(within(editor).getByTestId('channel-permission-row-r-1')).toHaveTextContent('@everyone'); + + // Включаем приватность: оверрайд @everyone получает запрет просмотра. + await userEvent.click(within(content).getByTestId('channel-private-toggle')); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0]?.body).toEqual({ allow: '', deny: 'VIEW_CHANNEL' }); + + // Разрешаем роли просмотр: в запросе появляется allow с именем права. + await userEvent.selectOptions( + within(editor).getByTestId('channel-permission-r-2-view_channel'), + 'allow', + ); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1]?.url).toContain('/overwrites/role/r-2'); + expect(calls[1]?.body).toEqual({ allow: 'VIEW_CHANNEL', deny: '' }); + + // Запрет переписки для @everyone уходит отдельным правом. + await userEvent.selectOptions( + within(editor).getByTestId('channel-permission-r-1-send_messages'), + 'deny', + ); + await waitFor(() => expect(calls).toHaveLength(3)); + expect(calls[2]?.body).toEqual({ allow: '', deny: 'SEND_MESSAGES' }); + }); + + it('смена прав повторяется после подтверждения личности', async () => { + const calls: { body: unknown }[] = []; + let requireStepUp = true; + const { content } = await openChannelSettings( + ['MANAGE_ROLES', 'MANAGE_GUILD'], + [ + { + match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-1', + method: 'PUT', + response: (request) => { + calls.push({ body: request.body }); + // Сервер требует свежее подтверждение личности (AGENT.md 9.3). + if (requireStepUp) { + requireStepUp = false; + return json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403); + } + return json({ channel }); + }, + }, + { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) }, + ], + ); + + await within(content).findByTestId('channel-permissions'); + await userEvent.click(within(content).getByTestId('channel-private-toggle')); + await waitFor(() => expect(calls).toHaveLength(1)); + + const stepUp = await within(content).findByTestId('channel-permissions-step-up'); + await userEvent.type(within(stepUp).getByLabelText('Ваш пароль'), 'correct-horse-battery'); + await userEvent.click(within(stepUp).getByRole('button', { name: 'Подтвердить' })); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1]?.body).toEqual({ allow: '', deny: 'VIEW_CHANNEL' }); + }); + + it('без права MANAGE_ROLES редактор доступа скрыт', async () => { + // Шестерёнка комнаты доступна по MANAGE_WEBHOOKS, но прав на доступ нет. + const { content } = await openChannelSettings(['MANAGE_WEBHOOKS', 'MANAGE_GUILD']); + await waitFor(() => + expect(within(content).queryByTestId('channel-permissions')).not.toBeInTheDocument(), + ); + }); +});