feat(invites): приглашения, каталог публичных серверов и публичность сервера
- 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 владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user