Files
glchat/internal/server/api_invites.go
T

415 lines
14 KiB
Go
Raw Normal View History

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"`
BannerFileID string `json:"banner_file_id,omitempty"`
SplashFileID string `json:"splash_file_id,omitempty"`
AccentColor int64 `json:"accent_color,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
}
// Суточная квота: 10 приглашений на пользователя (AGENT.md 8.6).
if !user.IsInstanceAdmin {
if allowed, retryAfter := s.inviteLimiter.Allow("invite:" + formatSnowflake(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
}
code, err := newInviteCode()
if err != nil {
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot generate invite code")
}
params := store.CreateInviteParams{
Code: code,
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)
}
// Бан сервера сильнее приглашения: снять его может только модератор.
if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
return nil, humaError(err)
} else if banned {
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
}
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 banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
return nil, humaError(err)
} else if banned {
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
}
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 banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
return nil, humaError(err)
} else if banned {
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
}
// Уже участник: повторное принятие не расходует использование.
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 guild.BannerFileID != nil {
output.Body.Guild.BannerFileID = formatSnowflake(*guild.BannerFileID)
}
if guild.SplashFileID != nil {
output.Body.Guild.SplashFileID = formatSnowflake(*guild.SplashFileID)
}
output.Body.Guild.AccentColor = guild.AccentColor
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 генерирует код приглашения криптографическим источником.
// Ошибка источника энтропии не маскируется слабым кодом: приглашение —
// это доступ к серверу, поэтому лучше вернуть ошибку (AGENT.md 9.2).
func newInviteCode() (string, error) {
const length = 10
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
var builder strings.Builder
builder.Grow(length)
for _, value := range buf {
builder.WriteByte(inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)])
}
return builder.String(), nil
}