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