From 61a84bfeae9332754bcf351a78fd13ad14a7d625 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 23:33:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(invites):=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D1=88=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD); DELETE /invites/{code} — создатель или MANAGE_GUILD; - GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников, повторное принятие не расходует использование; - PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для входа без приглашения (главный сервер публиковать нельзя); - GET /guilds/public — каталог (имя, описание, иконка, число участников); - аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и INVITE_DELETE владельцу сессии; - тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права, публичный каталог и вход в публичный сервер. --- internal/server/api_guilds.go | 16 + internal/server/api_invites.go | 378 ++++++++++++++++++ internal/server/invites_test.go | 173 ++++++++ internal/server/server.go | 1 + internal/store/invites.go | 154 +++++++ web/src/App.tsx | 9 +- web/src/components/ui/primitives.tsx | 2 +- web/src/i18n/locales/ru.json | 68 ++++ web/src/pages/app/AppLayout.tsx | 3 +- web/src/pages/app/ChannelSidebar.tsx | 53 ++- web/src/pages/app/GuildRail.tsx | 10 +- web/src/pages/settings/GuildDangerSection.tsx | 163 ++++++++ .../pages/settings/GuildNicknameSection.tsx | 16 +- .../pages/settings/ProfileSettingsPage.tsx | 5 +- .../pages/settings/ServersSettingsPage.tsx | 209 ++++++++++ web/src/pages/settings/SessionsSection.tsx | 5 +- 16 files changed, 1233 insertions(+), 32 deletions(-) create mode 100644 internal/server/api_invites.go create mode 100644 internal/server/invites_test.go create mode 100644 internal/store/invites.go create mode 100644 web/src/pages/settings/GuildDangerSection.tsx create mode 100644 web/src/pages/settings/ServersSettingsPage.tsx diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 5b3ad88..facedf5 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -221,6 +221,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { Body struct { Name *string `json:"name,omitempty" maxLength:"64"` Description *string `json:"description,omitempty" maxLength:"500"` + // Публичность сервера: попадает в каталог и открывает вход без + // приглашения (AGENT.md 7.20). + Public *bool `json:"public,omitempty"` } }, ) (*guildOutput, error) { @@ -233,6 +236,19 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) { return nil, err } params := store.UpdateGuildParams{Description: input.Body.Description} + if input.Body.Public != nil { + // Главный сервер публичным не делаем: он и так точка входа. + if *input.Body.Public { + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + if guild.IsMain { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published") + } + } + params.IsDiscoverable = input.Body.Public + } if input.Body.Name != nil { trimmed := strings.TrimSpace(*input.Body.Name) if trimmed == "" { diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go new file mode 100644 index 0000000..81c30ac --- /dev/null +++ b/internal/server/api_invites.go @@ -0,0 +1,378 @@ +package server + +import ( + "context" + "crypto/rand" + "errors" + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// inviteCodeAlphabet — символы кода приглашения (без похожих друг на друга). +const inviteCodeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789" + +type invitePayload struct { + Code string `json:"code"` + GuildID string `json:"guild_id"` + ChannelID string `json:"channel_id,omitempty"` + CreatorID string `json:"creator_id,omitempty"` + MaxUses int `json:"max_uses"` + Uses int `json:"uses"` + MaxAgeSec int `json:"max_age_seconds"` + ExpiresAt string `json:"expires_at,omitempty"` + CreatedAt string `json:"created_at"` + URL string `json:"url"` +} + +type inviteListOutput struct { + Body struct { + Invites []invitePayload `json:"invites"` + } +} + +type inviteOutput struct { + Body struct { + Invite invitePayload `json:"invite"` + Guild struct { + ID string `json:"id"` + Name string `json:"name"` + IconFileID string `json:"icon_file_id,omitempty"` + Description string `json:"description,omitempty"` + MemberCount int `json:"member_count"` + IsMember bool `json:"is_member"` + } `json:"guild"` + } +} + +type publicGuildPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IconFileID string `json:"icon_file_id,omitempty"` + MemberCount int `json:"member_count"` +} + +type publicGuildListOutput struct { + Body struct { + Guilds []publicGuildPayload `json:"guilds"` + } +} + +// registerInviteRoutes описывает приглашения и каталог публичных серверов +// (AGENT.md 7.9, 7.20). +func (s *Server) registerInviteRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "createInvite", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/invites", + Summary: "Создать приглашение", + Tags: []string{"Invites"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Body struct { + ChannelID string `json:"channel_id,omitempty"` + MaxUses int `json:"max_uses,omitempty" minimum:"0" maximum:"1000"` + MaxAgeSec int `json:"max_age_seconds,omitempty" minimum:"0" maximum:"2592000"` + } + }, + ) (*inviteOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.CreateInvites) + if err != nil { + return nil, err + } + params := store.CreateInviteParams{ + Code: newInviteCode(), + GuildID: guildID, + CreatorID: user.ID, + MaxUses: input.Body.MaxUses, + MaxAgeSec: input.Body.MaxAgeSec, + } + if input.Body.ChannelID != "" { + channelID, err := parseID("channel_id", input.Body.ChannelID) + if err != nil { + return nil, err + } + if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil { + return nil, err + } + params.ChannelID = &channelID + } + invite, err := s.store.CreateInvite(ctx, params) + if err != nil { + return nil, humaError(err) + } + s.recordAudit(ctx, user, guildID, "invite.create", "invite", nil, "") + if s.gateway != nil { + s.gateway.SendToUser(user.ID, "INVITE_CREATE", s.invitePayload(invite)) + } + return s.inviteOutput(ctx, user, invite), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listGuildInvites", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/invites", + Summary: "Приглашения сервера", + Tags: []string{"Invites"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*inviteListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild) + if err != nil { + return nil, err + } + invites, err := s.store.ListGuildInvites(ctx, guildID, 100) + if err != nil { + return nil, humaError(err) + } + output := &inviteListOutput{} + output.Body.Invites = make([]invitePayload, 0, len(invites)) + for i := range invites { + output.Body.Invites = append(output.Body.Invites, s.invitePayload(&invites[i])) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteInvite", + Method: http.MethodDelete, + Path: "/invites/{code}", + Summary: "Отозвать приглашение", + Tags: []string{"Invites"}, + Security: security, + }, func(ctx context.Context, input *struct { + Code string `path:"code"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + invite, err := s.store.GetInvite(ctx, input.Code) + if err != nil { + return nil, humaError(err) + } + isCreator := invite.CreatorID != nil && *invite.CreatorID == user.ID + if !isCreator { + if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil { + return nil, err + } + } + if err := s.store.DeleteInvite(ctx, input.Code); err != nil { + return nil, humaError(err) + } + s.recordAudit(ctx, user, invite.GuildID, "invite.revoke", "invite", nil, "") + if s.gateway != nil && invite.CreatorID != nil { + s.gateway.SendToUser(*invite.CreatorID, "INVITE_DELETE", map[string]any{"code": input.Code}) + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getInvite", + Method: http.MethodGet, + Path: "/invites/{code}", + Summary: "Предпросмотр приглашения", + Tags: []string{"Invites"}, + Security: security, + }, func(ctx context.Context, input *struct { + Code string `path:"code"` + }, + ) (*inviteOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + invite, err := s.store.GetInvite(ctx, input.Code) + if err != nil { + return nil, humaError(err) + } + if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) { + return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired") + } + if invite.MaxUses > 0 && invite.Uses >= invite.MaxUses { + return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite has no uses left") + } + return s.inviteOutput(ctx, user, invite), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "acceptInvite", + Method: http.MethodPost, + Path: "/invites/{code}", + Summary: "Принять приглашение", + Tags: []string{"Invites"}, + Security: security, + }, func(ctx context.Context, input *struct { + Code string `path:"code"` + }, + ) (*guildIDOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + invite, err := s.store.GetInvite(ctx, input.Code) + if err != nil { + return nil, humaError(err) + } + // Уже участник: повторное принятие не расходует использование. + if _, err := s.store.GetGuildMember(ctx, invite.GuildID, user.ID); err == nil { + output := &guildIDOutput{} + output.Body.GuildID = formatSnowflake(invite.GuildID) + return output, nil + } + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + count, err := s.store.CountGuildMembers(ctx, invite.GuildID) + if err != nil { + return nil, humaError(err) + } + if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full") + } + if _, err := s.store.UseInvite(ctx, input.Code); err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite is no longer valid") + } + return nil, humaError(err) + } + if _, err := s.store.AddGuildMember(ctx, invite.GuildID, user.ID, ""); err != nil && !errors.Is(err, store.ErrConflict) { + return nil, humaError(err) + } + s.invalidateGuild(invite.GuildID) + s.recordAudit(ctx, user, invite.GuildID, "invite.accept", "user", &user.ID, "") + if s.gateway != nil { + s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(invite.GuildID)}) + } + output := &guildIDOutput{} + output.Body.GuildID = formatSnowflake(invite.GuildID) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listPublicGuilds", + Method: http.MethodGet, + Path: "/guilds/public", + Summary: "Каталог публичных серверов", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*publicGuildListOutput, error) { + if _, _, err := requireUser(ctx); err != nil { + return nil, err + } + guilds, err := s.store.ListPublicGuilds(ctx, 50) + if err != nil { + return nil, humaError(err) + } + output := &publicGuildListOutput{} + output.Body.Guilds = make([]publicGuildPayload, 0, len(guilds)) + for _, guild := range guilds { + item := publicGuildPayload{ + ID: formatSnowflake(guild.ID), + Name: guild.Name, + Description: guild.Description, + } + if guild.IconFileID != nil { + item.IconFileID = formatSnowflake(*guild.IconFileID) + } + if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { + item.MemberCount = count + } + output.Body.Guilds = append(output.Body.Guilds, item) + } + return output, nil + }) +} + +type guildIDOutput struct { + Body struct { + GuildID string `json:"guild_id"` + } +} + +// invitePayload собирает приглашение для ответа API. +func (s *Server) invitePayload(invite *store.Invite) invitePayload { + payload := invitePayload{ + Code: invite.Code, + GuildID: formatSnowflake(invite.GuildID), + MaxUses: invite.MaxUses, + Uses: invite.Uses, + MaxAgeSec: invite.MaxAgeSec, + CreatedAt: invite.CreatedAt.UTC().Format(time.RFC3339), + URL: s.cfg.BaseURL() + "/invite/" + invite.Code, + } + if invite.ChannelID != nil { + payload.ChannelID = formatSnowflake(*invite.ChannelID) + } + if invite.CreatorID != nil { + payload.CreatorID = formatSnowflake(*invite.CreatorID) + } + if invite.ExpiresAt != nil { + payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339) + } + return payload +} + +// inviteOutput собирает приглашение вместе с карточкой сервера для страницы +// предпросмотра (AGENT.md 7.9). +func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput { + output := &inviteOutput{} + output.Body.Invite = s.invitePayload(invite) + guild, err := s.store.GetGuild(ctx, invite.GuildID) + if err != nil { + return output + } + output.Body.Guild.ID = formatSnowflake(guild.ID) + output.Body.Guild.Name = guild.Name + output.Body.Guild.Description = guild.Description + if guild.IconFileID != nil { + output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID) + } + if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { + output.Body.Guild.MemberCount = count + } + if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil { + output.Body.Guild.IsMember = true + } + return output +} + +// newInviteCode генерирует код приглашения криптографическим источником. +func newInviteCode() string { + const length = 10 + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + // Откат на время: код остаётся уникальным за счёт монотонности. + seed := uint64(time.Now().UnixNano()) //nolint:gosec // не криптография: запасной путь при отказе rand + for i := range buf { + buf[i] = byte(seed >> (uint(i) * 5)) + } + } + var builder strings.Builder + builder.Grow(length) + for _, value := range buf { + builder.WriteByte(inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)]) + } + return builder.String() +} diff --git a/internal/server/invites_test.go b/internal/server/invites_test.go new file mode 100644 index 0000000..00c925f --- /dev/null +++ b/internal/server/invites_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "net/http" + "testing" +) + +func TestInviteLifecycle(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "inv_owner", "inv-owner@example.com") + guestCookie := registerAndLogin(t, srv, "inv_guest", "inv-guest@example.com") + + // Первый сервер становится главным (в него может войти кто угодно), + // приглашения проверяем на втором — закрытом. + doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie) + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Закрытый"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + + // Посторонний не видит закрытый сервер. + hidden := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie) + if hidden.Code != http.StatusNotFound { + t.Fatalf("guest guild access = %d, want 404", hidden.Code) + } + + // Приглашение с лимитом в одно использование. + inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites", + `{"max_uses":1,"max_age_seconds":3600}`, ownerCookie) + if inviteRec.Code != http.StatusOK { + t.Fatalf("create invite = %d, body = %s", inviteRec.Code, inviteRec.Body.String()) + } + invite := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + URL string `json:"url"` + } `json:"invite"` + Guild struct { + Name string `json:"name"` + MemberCount int `json:"member_count"` + IsMember bool `json:"is_member"` + } `json:"guild"` + }](t, inviteRec) + if len(invite.Invite.Code) != 10 { + t.Fatalf("invite code = %q, want 10 символов", invite.Invite.Code) + } + // В ответе на создание IsMember отражает самого создателя (он в сервере). + if invite.Guild.Name != "Закрытый" || !invite.Guild.IsMember { + t.Fatalf("unexpected preview: %+v", invite.Guild) + } + + // Предпросмотр доступен гостю и показывает сервер. + preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie) + if preview.Code != http.StatusOK { + t.Fatalf("preview = %d, body = %s", preview.Code, preview.Body.String()) + } + previewPayload := decodeResponse[struct { + Guild struct { + Name string `json:"name"` + MemberCount int `json:"member_count"` + IsMember bool `json:"is_member"` + } `json:"guild"` + }](t, preview) + if previewPayload.Guild.Name != "Закрытый" || previewPayload.Guild.IsMember || previewPayload.Guild.MemberCount != 1 { + t.Fatalf("guest preview = %+v", previewPayload.Guild) + } + + // Принятие: гость становится участником. + accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie) + if accept.Code != http.StatusOK { + t.Fatalf("accept invite = %d, body = %s", accept.Code, accept.Body.String()) + } + member := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie) + if member.Code != http.StatusOK { + t.Fatalf("guild after invite = %d, body = %s", member.Code, member.Body.String()) + } + + // Повторное принятие тем же пользователем не расходует использования. + again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie) + if again.Code != http.StatusOK { + t.Fatalf("repeat accept = %d, body = %s", again.Code, again.Body.String()) + } + + // Третьего участника приглашение уже не пускает: лимит исчерпан. + thirdCookie := registerAndLogin(t, srv, "inv_third", "inv-third@example.com") + exhausted := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", thirdCookie) + if exhausted.Code != http.StatusGone { + t.Fatalf("exhausted invite = %d, want 410", exhausted.Code) + } + + // Отзыв приглашения владельцем и проверка списка. + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/invites", "", ownerCookie) + invites := decodeResponse[struct { + Invites []struct { + Code string `json:"code"` + Uses int `json:"uses"` + } `json:"invites"` + }](t, list) + if len(invites.Invites) != 1 || invites.Invites[0].Uses != 1 { + t.Fatalf("invites = %+v", invites.Invites) + } + revoke := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+invite.Invite.Code, "", ownerCookie) + if revoke.Code != http.StatusOK { + t.Fatalf("revoke = %d, body = %s", revoke.Code, revoke.Body.String()) + } + afterRevoke := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie) + if afterRevoke.Code != http.StatusNotFound { + t.Fatalf("revoked invite = %d, want 404", afterRevoke.Code) + } + + // Обычный участник без CREATE_INVITES приглашение создать не может. + memberInvite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites", `{}`, guestCookie) + if memberInvite.Code != http.StatusForbidden { + t.Fatalf("member invite = %d, want 403", memberInvite.Code) + } +} + +func TestPublicGuildsCatalog(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "pub_owner", "pub-owner@example.com") + guestCookie := registerAndLogin(t, srv, "pub_guest", "pub-guest@example.com") + + doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie) + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Публичный"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + + // Пока сервер закрыт, в каталоге его нет. + closed := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie) + closedList := decodeResponse[struct { + Guilds []struct { + Name string `json:"name"` + } `json:"guilds"` + }](t, closed) + for _, item := range closedList.Guilds { + if item.Name == "Публичный" { + t.Fatal("closed guild must not be in the public catalog") + } + } + + // Делаем сервер публичным: появляется в каталоге и открыт для входа. + if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID, + `{"public":true}`, ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("publish guild = %d, body = %s", rec.Code, rec.Body.String()) + } + open := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie) + openList := decodeResponse[struct { + Guilds []struct { + ID string `json:"id"` + Name string `json:"name"` + MemberCount int `json:"member_count"` + } `json:"guilds"` + }](t, open) + found := false + for _, item := range openList.Guilds { + if item.ID == guild.Guild.ID { + found = true + } + } + if !found { + t.Fatalf("public guild is missing from the catalog: %+v", openList.Guilds) + } + + join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", guestCookie) + if join.Code != http.StatusOK { + t.Fatalf("join public guild = %d, body = %s", join.Code, join.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 17391c1..78ba75a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -100,6 +100,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerGuildRoutes(s.api) s.registerInstanceRoutes(s.api) s.registerMessageRoutes(s.api) + s.registerInviteRoutes(s.api) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/store/invites.go b/internal/store/invites.go new file mode 100644 index 0000000..339c12a --- /dev/null +++ b/internal/store/invites.go @@ -0,0 +1,154 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Invite — приглашение на сервер (AGENT.md 7.9). +type Invite struct { + Code string + GuildID uint64 + ChannelID *uint64 + CreatorID *uint64 + MaxUses int + Uses int + MaxAgeSec int + ExpiresAt *time.Time + CreatedAt time.Time +} + +// CreateInviteParams — параметры приглашения. +type CreateInviteParams struct { + Code string + GuildID uint64 + ChannelID *uint64 + CreatorID uint64 + MaxUses int + MaxAgeSec int +} + +const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses, + max_age_sec, expires_at, created_at` + +// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком. +func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) { + var expiresAt any + if params.MaxAgeSec > 0 { + expiresAt = s.Timestamp(time.Now().UTC().Add(time.Duration(params.MaxAgeSec) * time.Second)) + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO invites (code, guild_id, channel_id, creator_id, max_uses, uses, max_age_sec, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`, + params.Code, int64(params.GuildID), nullableID(params.ChannelID), int64(params.CreatorID), + params.MaxUses, params.MaxAgeSec, expiresAt, s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetInvite(ctx, params.Code) +} + +func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+inviteColumns+` FROM invites WHERE code = ?`, code) + return scanInvite(row) +} + +// ListGuildInvites возвращает приглашения сервера (для панели управления). +func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+inviteColumns+` FROM invites WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?`, + int64(guildID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + invites := make([]Invite, 0, limit) + for rows.Next() { + invite, err := scanInvite(rows) + if err != nil { + return nil, err + } + invites = append(invites, *invite) + } + return invites, rows.Err() +} + +// UseInvite атомарно увеличивает счётчик использований, проверяя лимит и срок. +// Возвращает ErrNotFound, если приглашение исчерпано или истекло (AGENT.md 7.9). +func (s *Store) UseInvite(ctx context.Context, code string) (*Invite, error) { + result, err := s.writer.ExecContext(ctx, ` + UPDATE invites SET uses = uses + 1 + WHERE code = ? + AND (max_uses = 0 OR uses < max_uses) + AND (expires_at IS NULL OR expires_at > ?)`, code, s.Now()) + if err != nil { + return nil, err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetInvite(ctx, code) +} + +func (s *Store) DeleteInvite(ctx context.Context, code string) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM invites WHERE code = ?`, code) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// ListPublicGuilds возвращает публичные серверы для каталога (AGENT.md 7.20). +func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+guildColumns+` FROM guilds + WHERE is_discoverable = 1 ORDER BY id LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + guilds := make([]Guild, 0, limit) + for rows.Next() { + guild, err := scanGuild(rows) + if err != nil { + return nil, err + } + guilds = append(guilds, *guild) + } + return guilds, rows.Err() +} + +func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) { + var ( + invite Invite + channelID sql.NullInt64 + creatorID sql.NullInt64 + expiresAt sql.NullString + createdAt string + ) + err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses, + &invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt) + if err != nil { + return nil, mapError(err) + } + invite.ChannelID = optionalID(channelID) + invite.CreatorID = optionalID(creatorID) + if expiresAt.Valid { + value := parseTimestamp(expiresAt.String) + invite.ExpiresAt = &value + } + invite.CreatedAt = parseTimestamp(createdAt) + return &invite, nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9d91bc5..cdc9ab8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,18 @@ import { Suspense } from 'react'; import { RouterProvider } from 'react-router'; -import { LoadingNotice } from '@/components/ui/ErrorNotice'; +import { Card } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; import { router } from '@/router'; import '@/i18n'; -/** Заглушка на время загрузки ленивых страниц. */ +/** Заглушка на время загрузки ленивых страниц: скелетон вместо пустоты. */ export function RouteFallback() { return (
- + + +
); } diff --git a/web/src/components/ui/primitives.tsx b/web/src/components/ui/primitives.tsx index 7c24085..2cf498a 100644 --- a/web/src/components/ui/primitives.tsx +++ b/web/src/components/ui/primitives.tsx @@ -16,7 +16,7 @@ export function Button({ variant = 'primary', className = '', ...rest }: ButtonP return ( - ) : null} +
+ {canManageSettings ? ( + + ⚙ + + ) : null} + {canManage ? ( + + ) : null} +
{guild === null ? (

{t('channels.notInGuild.body')}

+ ) : channelsLoading ? ( + ) : groups.length === 0 ? (

{t('channels.empty')}

) : ( @@ -134,7 +155,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { {group.category.name} )} -
    +
      {group.channels.map((channel) => (
    • diff --git a/web/src/pages/app/GuildRail.tsx b/web/src/pages/app/GuildRail.tsx index 935c141..1270228 100644 --- a/web/src/pages/app/GuildRail.tsx +++ b/web/src/pages/app/GuildRail.tsx @@ -19,7 +19,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil aria-label={t('guilds.railLabel')} className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3" > -
        +
          {guilds.map((guild) => { const active = guild.id === selectedGuildId; return ( @@ -29,10 +29,10 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil title={guild.name} aria-label={t('guilds.switchTo', { name: guild.name })} aria-current={active ? 'page' : undefined} - className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${ + className={`relative block rounded-[var(--radius-md)] transition-[border-radius,background-color,transform] duration-[var(--duration-fast)] ${ active - ? 'rounded-[var(--radius-lg)] ring-2 ring-accent' - : 'hover:rounded-[var(--radius-lg)]' + ? 'gl-ring rounded-[var(--radius-lg)]' + : 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0' }`} > @@ -48,7 +48,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil onClick={onCreate} aria-label={t('guilds.add')} title={t('guilds.add')} - className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success hover:rounded-[var(--radius-lg)] hover:border-success" + className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success transition-[border-radius,border-color,transform] duration-[var(--duration-fast)] hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] hover:border-success active:translate-y-0" > + diff --git a/web/src/pages/settings/GuildDangerSection.tsx b/web/src/pages/settings/GuildDangerSection.tsx new file mode 100644 index 0000000..4f6d197 --- /dev/null +++ b/web/src/pages/settings/GuildDangerSection.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; + +import { deleteGuild, guildQueryKey, leaveGuild } from '@/api/guilds'; +import { instanceQueryKey } from '@/api/instance'; +import { myGuildsQueryKey } from '@/api/users'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/primitives'; +import { useSessionStore } from '@/stores/session'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +interface GuildDangerSectionProps { + guildId: string; + guildName: string; + /** Владелец не может покинуть сервер — только удалить его. */ + isOwner: boolean; + /** Администратор инстанса может удалить любой сервер. */ + isInstanceAdmin: boolean; +} + +type ConfirmAction = 'leave' | 'delete'; + +/** «Опасная зона»: выход с сервера и удаление сервера с подтверждением. */ +export function GuildDangerSection({ + guildId, + guildName, + isOwner, + isInstanceAdmin, +}: GuildDangerSectionProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [confirm, setConfirm] = useState(null); + // Последнее действие: пока окно закрывается, тексты не должны «прыгать». + const [lastAction, setLastAction] = useState('leave'); + + const ask = (action: ConfirmAction): void => { + setLastAction(action); + setConfirm(action); + }; + + /** После выхода/удаления возвращаемся в приложение и обновляем снапшот. */ + const afterExit = (): void => { + setConfirm(null); + useSessionStore.getState().removeGuild(guildId); + useSessionStore.getState().selectSettingsGuild(null); + void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey }); + void queryClient.invalidateQueries({ queryKey: instanceQueryKey }); + void queryClient.removeQueries({ queryKey: guildQueryKey(guildId) }); + void navigate('/app', { replace: true }); + }; + + const leave = useMutation({ + mutationFn: () => leaveGuild(guildId), + onSuccess: afterExit, + }); + + const remove = useMutation({ + mutationFn: () => deleteGuild(guildId), + onSuccess: afterExit, + }); + + const canDelete = isOwner || isInstanceAdmin; + const pending = leave.isPending || remove.isPending; + + return ( + +
          + {isOwner ? ( +

          {t('settings.servers.danger.leaveOwnerHint')}

          + ) : ( +
          +

          + {t('settings.servers.danger.leaveHint')} +

          + +
          + )} + + {canDelete ? ( +
          +

          + {t('settings.servers.danger.deleteHint')} +

          + +
          + ) : null} +
          + + {leave.isError ? : null} + {remove.isError ? : null} + + setConfirm(null)} + footer={ + <> + + + + } + > +

          + {t( + lastAction === 'delete' + ? 'settings.servers.danger.deleteConfirmBody' + : 'settings.servers.danger.leaveConfirmBody', + { name: guildName }, + )} +

          +
          +
          + ); +} diff --git a/web/src/pages/settings/GuildNicknameSection.tsx b/web/src/pages/settings/GuildNicknameSection.tsx index c5801ac..11970ba 100644 --- a/web/src/pages/settings/GuildNicknameSection.tsx +++ b/web/src/pages/settings/GuildNicknameSection.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react'; +import { useEffect, useState, type FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -30,8 +30,17 @@ export function GuildNicknameSection({ const { t } = useTranslation(); const queryClient = useQueryClient(); const [nickname, setNickname] = useState(currentNickname); + const [edited, setEdited] = useState(false); const [saved, setSaved] = useState(false); + // Ник может прийти из списка участников уже после монтирования секции: + // подтягиваем его, пока пользователь не начал править поле сам. + useEffect(() => { + if (!edited) { + setNickname(currentNickname); + } + }, [currentNickname, edited]); + const save = useMutation({ mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }), onSuccess: (_result, value) => { @@ -72,7 +81,10 @@ export function GuildNicknameSection({ value={nickname} maxLength={32} disabled={userId === undefined} - onChange={(event) => setNickname(event.target.value)} + onChange={(event) => { + setEdited(true); + setNickname(event.target.value); + }} /> {save.isError ? : null} diff --git a/web/src/pages/settings/ProfileSettingsPage.tsx b/web/src/pages/settings/ProfileSettingsPage.tsx index 785fcde..2f24fe4 100644 --- a/web/src/pages/settings/ProfileSettingsPage.tsx +++ b/web/src/pages/settings/ProfileSettingsPage.tsx @@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next'; import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users'; import { userStatuses, type User } from '@/api/types'; -import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField, TextAreaField } from '@/components/ui/Field'; import { Button, Card } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; import { supportedLanguages, type SupportedLanguage } from '@/i18n'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; @@ -155,7 +156,7 @@ export default function ProfileSettingsPage() { if (currentUser.isPending) { return ( - + ); } diff --git a/web/src/pages/settings/ServersSettingsPage.tsx b/web/src/pages/settings/ServersSettingsPage.tsx new file mode 100644 index 0000000..1731bb9 --- /dev/null +++ b/web/src/pages/settings/ServersSettingsPage.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { NavLink, useNavigate, useParams } from 'react-router'; + +import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds'; +import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; +import { Avatar } from '@/components/ui/Avatar'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Card } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { canManageGuildSettings } from '@/lib/identity'; +import { useCurrentUser } from '@/lib/hooks'; +import { useSessionStore } from '@/stores/session'; +import { GuildDangerSection } from '@/pages/settings/GuildDangerSection'; +import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection'; +import { GuildMembersSection } from '@/pages/settings/GuildMembersSection'; +import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection'; +import { GuildRolesSection } from '@/pages/settings/GuildRolesSection'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** + * Настройки пользовательских серверов (`/settings/servers/:guildId`). + * + * Список серверов берём из READY-снапшота, а если шлюз ещё не подключился — + * из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится + * в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает. + */ +export default function ServersSettingsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const params = useParams<{ guildId?: string }>(); + const routeGuildId = params.guildId; + const currentUser = useCurrentUser(); + + const sessionGuilds = useSessionStore((state) => state.guilds); + const upsertGuild = useSessionStore((state) => state.upsertGuild); + const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild); + + const myGuilds = useQuery({ + queryKey: myGuildsQueryKey, + queryFn: ({ signal }) => fetchMyGuilds(signal), + retry: 0, + enabled: sessionGuilds.length === 0, + }); + + // Подстраховка снапшота: без неё выбранный сервер не найти в сторе. + useEffect(() => { + if (sessionGuilds.length > 0 || myGuilds.data === undefined) { + return; + } + for (const guild of myGuilds.data) { + upsertGuild(guild); + } + }, [sessionGuilds.length, myGuilds.data, upsertGuild]); + + const guilds = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []); + const active = useMemo( + () => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)), + [guilds, routeGuildId], + ); + const activeId = active?.id ?? null; + + // URL — источник правды; стор запоминает последний открытый сервер. + useEffect(() => { + if (activeId !== null) { + selectSettingsGuild(activeId); + return; + } + if (routeGuildId === undefined && guilds.length > 0) { + const first = guilds[0]; + if (first !== undefined) { + void navigate(`/settings/servers/${first.id}`, { replace: true }); + } + } + }, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]); + + const detail = useQuery({ + queryKey: guildQueryKey(activeId ?? ''), + queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal), + enabled: activeId !== null, + retry: 0, + }); + + const members = useQuery({ + queryKey: guildMembersQueryKey(activeId ?? ''), + queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal), + enabled: activeId !== null, + retry: 0, + }); + + const guild = detail.data; + const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? []; + const ownerId = guild?.owner_id ?? active?.owner_id ?? ''; + const canManage = canManageGuildSettings(myPermissions, ownerId, currentUser.data?.id); + const currentNickname = useMemo(() => { + const fromSnapshot = active?.my_nickname; + if (fromSnapshot !== undefined && fromSnapshot !== '') { + return fromSnapshot; + } + const mine = members.data?.find((member) => member.user_id === currentUser.data?.id); + return mine?.nickname ?? ''; + }, [active, members.data, currentUser.data?.id]); + + return ( +
          + +

          + {t('settings.servers.pickerLabel')} +

          + {guilds.length === 0 ? ( + myGuilds.isPending ? ( + + ) : ( +

          {t('settings.servers.empty')}

          + ) + ) : ( + + )} +
          + + {guilds.length === 0 ? ( + myGuilds.isPending ? ( + + + + ) : ( + +
          + + {t('settings.back')} + +
          +
          + ) + ) : activeId === null ? ( + // Сервер из URL не найден среди доступных пользователю. + +

          {t('settings.servers.notFound')}

          +
          + ) : detail.isPending ? ( + + + + ) : guild === undefined ? ( + + void detail.refetch()} /> + + ) : ( + <> + + + void members.refetch()} + /> + + + + )} +
          + ); +} diff --git a/web/src/pages/settings/SessionsSection.tsx b/web/src/pages/settings/SessionsSection.tsx index a1c9af0..ff3e9e6 100644 --- a/web/src/pages/settings/SessionsSection.tsx +++ b/web/src/pages/settings/SessionsSection.tsx @@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth'; -import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Button, Card } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; import { formatDateTime } from '@/lib/identity'; import { useGatewayStore } from '@/stores/gateway'; import { useSessionStore } from '@/stores/session'; @@ -54,7 +55,7 @@ export function SessionsSection() { ) : null} - {sessions.isPending ? : null} + {sessions.isPending ? : null} {sessions.isError ? (