8619f37cd5
- упоминания в сообщении увеличивают счётчик упоминаний комнаты у упомянутых (store.BumpMentionCount) и отправляют им READ_STATE_UPDATE на все устройства; - отправка сообщения отмечает его прочитанным для автора; - подтверждение прочтения сбрасывает упоминания; - суточная квота 10 приглашений на пользователя (AGENT.md 8.6), администратор инстанса её обходит; httpx.NewRateLimiterWindow для произвольных окон; - тест на рост и сброс счётчика упоминаний.
387 lines
12 KiB
Go
387 lines
12 KiB
Go
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
|
|
}
|
|
// Суточная квота: 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)
|
|
}
|
|
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 генерирует код приглашения криптографическим источником.
|
|
// Ошибка источника энтропии не маскируется слабым кодом: приглашение —
|
|
// это доступ к серверу, поэтому лучше вернуть ошибку (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
|
|
}
|