package server import ( "bytes" "context" "crypto/rand" "errors" "io" "net/http" "strings" "time" "github.com/danielgtaylor/huma/v2" "github.com/go-chi/chi/v5" "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"` // BackgroundFileID — переопределение splash для страницы приглашения // (AGENT.md 7.9). BackgroundFileID string `json:"background_file_id,omitempty"` } 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"` // Inviter — кто позвал: страница приглашения показывает имя. Inviter struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` AvatarFileID string `json:"avatar_file_id,omitempty"` } `json:"inviter"` } `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"}, // Карточку приглашения видно и без сессии: по ссылке приходят те, у кого // аккаунта ещё нет (AGENT.md 7.9). Присоединение требует входа. }, func(ctx context.Context, input *struct { Code string `path:"code"` }, ) (*inviteOutput, error) { user, _, _ := sessionFromContext(ctx) invite, err := s.store.GetInvite(ctx, input.Code) if err != nil { return nil, humaError(err) } // Бан сервера сильнее приглашения: снять его может только модератор. if user != nil { 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) } if invite.BackgroundFileID != nil { payload.BackgroundFileID = formatSnowflake(*invite.BackgroundFileID) } return payload } // inviteOutput собирает приглашение вместе с карточкой сервера для страницы // предпросмотра (AGENT.md 7.9). `user` может быть nil — гость по ссылке. 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 user != nil { if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil { output.Body.Guild.IsMember = true } } // Пригласившего показываем по имени: это часть карточки приглашения. if invite.CreatorID != nil { if creator, err := s.store.GetUser(ctx, *invite.CreatorID); err == nil { output.Body.Guild.Inviter.ID = formatSnowflake(creator.ID) output.Body.Guild.Inviter.Username = creator.Username output.Body.Guild.Inviter.DisplayName = creator.DisplayName if creator.AvatarFileID != nil { output.Body.Guild.Inviter.AvatarFileID = formatSnowflake(*creator.AvatarFileID) } } } 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 } // maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7). const maxInviteBackgroundSize = 5 << 20 // registerInviteBackgroundRoutes описывает картинку приглашения: создатель // приглашения или MANAGE_GUILD сервера может задать своё оформление страницы // (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере. func (s *Server) registerInviteBackgroundRoutes(router chi.Router) { router.Post("/invites/{code}/background", s.handleInviteBackgroundUpload) router.Delete("/invites/{code}/background", s.handleInviteBackgroundDelete) } func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } ctx := r.Context() invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user) if err != nil { writeHumaAPIError(w, err) return } file, header, err := r.FormFile("file") if err != nil { httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) return } defer func() { _ = file.Close() }() if header.Size > maxInviteBackgroundSize { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") return } data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1)) if err != nil || int64(len(data)) > maxInviteBackgroundSize { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") return } contentType := header.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { contentType = http.DetectContentType(data) } if !strings.HasPrefix(contentType, "image/") { httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invite background must be an image") return } // Старую картинку удаляем: загрузки не должны копиться (AGENT.md 7.7). if previous := invite.BackgroundFileID; previous != nil && *previous != 0 { s.deleteStoredFile(ctx, *previous) } stored, err := s.saveUpload(ctx, store.File{ UploaderID: &user.ID, GuildID: &invite.GuildID, Filename: sanitizeFilename(header.Filename), ContentType: contentType, Purpose: "invite_background", }, bytes.NewReader(data)) if err != nil { writeHumaAPIError(w, err) return } updated, err := s.store.SetInviteBackground(ctx, invite.Code, &stored.ID) if err != nil { s.deleteStoredFile(ctx, stored.ID) writeHumaAPIError(w, humaError(err)) return } s.recordAudit(ctx, user, invite.GuildID, "invite.background", "invite", nil, invite.Code) httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)}) } func (s *Server) handleInviteBackgroundDelete(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } ctx := r.Context() invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user) if err != nil { writeHumaAPIError(w, err) return } if previous := invite.BackgroundFileID; previous != nil && *previous != 0 { s.deleteStoredFile(ctx, *previous) } updated, err := s.store.SetInviteBackground(ctx, invite.Code, nil) if err != nil { writeHumaAPIError(w, humaError(err)) return } s.recordAudit(ctx, user, invite.GuildID, "invite.background_remove", "invite", nil, invite.Code) httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)}) } // requireInviteManage достаёт приглашение и проверяет право менять его // оформление: создатель или MANAGE_GUILD сервера (AGENT.md 7.9). func (s *Server) requireInviteManage(ctx context.Context, code string, user *store.User) (*store.Invite, error) { invite, err := s.store.GetInvite(ctx, code) if err != nil { return nil, humaError(err) } if invite.CreatorID != nil && *invite.CreatorID == user.ID { return invite, nil } if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil { return nil, err } return invite, nil }