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} )} -