diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 650e5ef0..8b6b2d0a 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -53,7 +53,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 94aa7b99..51f02df4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/.gitignore b/.gitignore index 8c8040f5..80a78256 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dist-ssr *.njsproj *.sln *.sw? + +go.work +go.work.sum diff --git a/backend/app/http/endpoints/api/admin/affiliate/create.go b/backend/app/http/endpoints/api/admin/affiliate/create.go index 5453feb9..9077a872 100644 --- a/backend/app/http/endpoints/api/admin/affiliate/create.go +++ b/backend/app/http/endpoints/api/admin/affiliate/create.go @@ -122,11 +122,11 @@ func CreateHandler(ctx *gin.Context) { ResourceType: database.AuditResourceAffiliate, ResourceId: audit.StringPtr(affiliateCode.Id.String()), NewData: map[string]any{ - "target_user_id": body.UserId, - "code": body.Code, + "target_user_id": body.UserId, + "code": body.Code, "discount_basis_points": body.DiscountBasisPoints, - "credit_percentage": body.CreditPercentage, - "polar_discount_id": polarDiscountId, + "credit_percentage": body.CreditPercentage, + "polar_discount_id": polarDiscountId, }, }) diff --git a/backend/app/http/endpoints/api/admin/affiliate/list.go b/backend/app/http/endpoints/api/admin/affiliate/list.go index fa98fa6a..2e4eb657 100644 --- a/backend/app/http/endpoints/api/admin/affiliate/list.go +++ b/backend/app/http/endpoints/api/admin/affiliate/list.go @@ -19,21 +19,21 @@ const ( ) type affiliateCodeEntry struct { - Id string `json:"id"` - UserId string `json:"user_id"` - Username string `json:"username"` - AvatarUrl string `json:"avatar_url,omitempty"` - Code string `json:"code"` - PolarDiscountId *string `json:"polar_discount_id"` - Status string `json:"status"` - DiscountBasisPoints int `json:"discount_basis_points"` - CreditPercentage *int `json:"credit_percentage"` - CreatedAt string `json:"created_at"` - ApprovedAt *string `json:"approved_at"` - ApprovedBy *string `json:"approved_by"` - RevokedAt *string `json:"revoked_at"` - TotalReferrals int `json:"total_referrals"` - RedeemedCredits int `json:"redeemed_credits"` + Id string `json:"id"` + UserId string `json:"user_id"` + Username string `json:"username"` + AvatarUrl string `json:"avatar_url,omitempty"` + Code string `json:"code"` + PolarDiscountId *string `json:"polar_discount_id"` + Status string `json:"status"` + DiscountBasisPoints int `json:"discount_basis_points"` + CreditPercentage *int `json:"credit_percentage"` + CreatedAt string `json:"created_at"` + ApprovedAt *string `json:"approved_at"` + ApprovedBy *string `json:"approved_by"` + RevokedAt *string `json:"revoked_at"` + TotalReferrals int `json:"total_referrals"` + RedeemedCredits int `json:"redeemed_credits"` } type listResponse struct { diff --git a/backend/app/http/endpoints/api/admin/affiliate/rates.go b/backend/app/http/endpoints/api/admin/affiliate/rates.go index 5612b6b6..860c653c 100644 --- a/backend/app/http/endpoints/api/admin/affiliate/rates.go +++ b/backend/app/http/endpoints/api/admin/affiliate/rates.go @@ -93,11 +93,11 @@ func UpdateRatesHandler(ctx *gin.Context) { ResourceId: audit.StringPtr(affiliateId.String()), OldData: map[string]any{ "discount_basis_points": oldBasisPoints, - "credit_percentage": oldCreditPercentage, + "credit_percentage": oldCreditPercentage, }, NewData: map[string]any{ "discount_basis_points": body.DiscountBasisPoints, - "credit_percentage": body.CreditPercentage, + "credit_percentage": body.CreditPercentage, }, }) diff --git a/backend/app/http/endpoints/api/admin/affiliate/referrals.go b/backend/app/http/endpoints/api/admin/affiliate/referrals.go index 28712a98..8280668a 100644 --- a/backend/app/http/endpoints/api/admin/affiliate/referrals.go +++ b/backend/app/http/endpoints/api/admin/affiliate/referrals.go @@ -13,14 +13,14 @@ import ( ) type adminReferralEntry struct { - Id string `json:"id"` - ReferredTier string `json:"referred_tier"` - PurchasedDays int `json:"purchased_days"` - CreditDays int `json:"credit_days"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - RedeemableAt string `json:"redeemable_at"` - RedeemedAt *string `json:"redeemed_at"` + Id string `json:"id"` + ReferredTier string `json:"referred_tier"` + PurchasedDays int `json:"purchased_days"` + CreditDays int `json:"credit_days"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + RedeemableAt string `json:"redeemable_at"` + RedeemedAt *string `json:"redeemed_at"` } type adminReferralsResponse struct { diff --git a/backend/app/http/endpoints/api/admin/analytics/usage.go b/backend/app/http/endpoints/api/admin/analytics/usage.go index 0f283957..09aed002 100644 --- a/backend/app/http/endpoints/api/admin/analytics/usage.go +++ b/backend/app/http/endpoints/api/admin/analytics/usage.go @@ -13,8 +13,8 @@ import ( ) type usageResponse struct { - Metrics database.GlobalUsageMetrics `json:"metrics"` - TicketsPerDay []database.GlobalTicketsPerDay `json:"tickets_per_day"` + Metrics database.GlobalUsageMetrics `json:"metrics"` + TicketsPerDay []database.GlobalTicketsPerDay `json:"tickets_per_day"` } func GetUsageHandler(ctx *gin.Context) { diff --git a/backend/app/http/endpoints/api/admin/featureflags/list.go b/backend/app/http/endpoints/api/admin/featureflags/list.go index 1cd9487c..3772daee 100644 --- a/backend/app/http/endpoints/api/admin/featureflags/list.go +++ b/backend/app/http/endpoints/api/admin/featureflags/list.go @@ -15,9 +15,9 @@ type listResponse struct { Flags []flagResponse `json:"flags"` // Environments is what GrowthBook actually has configured, so the UI offers // real names instead of assuming production and staging. - Environments []string `json:"environments"` - Configured bool `json:"configured"` - FlagsDefaultOn bool `json:"flags_default_on"` + Environments []string `json:"environments"` + Configured bool `json:"configured"` + FlagsDefaultOn bool `json:"flags_default_on"` } type flagResponse struct { diff --git a/backend/app/http/endpoints/api/affiliate/redeem.go b/backend/app/http/endpoints/api/affiliate/redeem.go index fb1d1303..3c885beb 100644 --- a/backend/app/http/endpoints/api/affiliate/redeem.go +++ b/backend/app/http/endpoints/api/affiliate/redeem.go @@ -222,12 +222,12 @@ func Redeem(ctx *gin.Context) { ResourceType: database.AuditResourceAffiliateReferral, ResourceId: audit.StringPtr(affiliateCode.Id.String()), NewData: map[string]any{ - "redeemed_count": result.redeemedCount, - "total_credits": result.totalCredits, - "total_days": result.totalDays, - "tier": result.tier, - "method": result.method, - "referral_ids": result.referralIds, + "redeemed_count": result.redeemedCount, + "total_credits": result.totalCredits, + "total_days": result.totalDays, + "tier": result.tier, + "method": result.method, + "referral_ids": result.referralIds, }, }) diff --git a/backend/app/http/endpoints/api/affiliate/referrals.go b/backend/app/http/endpoints/api/affiliate/referrals.go index dc83ff89..7a8c937f 100644 --- a/backend/app/http/endpoints/api/affiliate/referrals.go +++ b/backend/app/http/endpoints/api/affiliate/referrals.go @@ -17,14 +17,14 @@ const ( ) type referralResponse struct { - Id string `json:"id"` - ReferredTier string `json:"referred_tier"` - PurchasedDays int `json:"purchased_days"` - CreditDays int `json:"credit_days"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - RedeemableAt string `json:"redeemable_at"` - RedeemedAt *string `json:"redeemed_at"` + Id string `json:"id"` + ReferredTier string `json:"referred_tier"` + PurchasedDays int `json:"purchased_days"` + CreditDays int `json:"credit_days"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + RedeemableAt string `json:"redeemable_at"` + RedeemedAt *string `json:"redeemed_at"` } type referralsListResponse struct { diff --git a/backend/app/http/endpoints/api/affiliate/status.go b/backend/app/http/endpoints/api/affiliate/status.go index 083e5d98..1bd81446 100644 --- a/backend/app/http/endpoints/api/affiliate/status.go +++ b/backend/app/http/endpoints/api/affiliate/status.go @@ -53,12 +53,12 @@ type statusResponse struct { } type affiliateCodeResponse struct { - Id string `json:"id"` - Code string `json:"code"` - Status string `json:"status"` - DiscountBasisPoints int `json:"discount_basis_points"` - CreditPercentage *int `json:"credit_percentage"` - CreatedAt string `json:"created_at"` + Id string `json:"id"` + Code string `json:"code"` + Status string `json:"status"` + DiscountBasisPoints int `json:"discount_basis_points"` + CreditPercentage *int `json:"credit_percentage"` + CreatedAt string `json:"created_at"` ApprovedAt *string `json:"approved_at"` } diff --git a/backend/app/http/endpoints/api/analytics/panels.go b/backend/app/http/endpoints/api/analytics/panels.go index 83915aa2..cbf41a82 100644 --- a/backend/app/http/endpoints/api/analytics/panels.go +++ b/backend/app/http/endpoints/api/analytics/panels.go @@ -180,7 +180,7 @@ agg AS ( GROUP BY panel_id ) SELECT - p.panel_id, p.title, p.channel_id, p.disabled, p.force_disabled, + p.panel_id, COALESCE(NULLIF(p.name, ''), p.title), p.channel_id, p.disabled, p.force_disabled, COALESCE(a.ticket_count, 0), COALESCE(a.closed_count, 0), a.avg_frt_secs, a.avg_resolution_secs, a.avg_rating, COALESCE(a.rating_count, 0), COALESCE(a.prev_ticket_count, 0), COALESCE(a.prev_closed_count, 0), @@ -321,7 +321,7 @@ GROUP BY p.panel_id, p.default_team;` group.Go(func() error { // The primary key is "id". "multi_panel_id" is the FK name on // multi_panel_targets, not a column here. - query := `SELECT id, embed->>'title' FROM multi_panels WHERE guild_id = $1;` + query := `SELECT id, COALESCE(NULLIF(name, ''), embed->>'title') FROM multi_panels WHERE guild_id = $1;` rows, err := dbclient.Client.Tickets.Query(groupCtx, query, guildId) if err != nil { return err diff --git a/backend/app/http/endpoints/api/gallery/galleryimport.go b/backend/app/http/endpoints/api/gallery/galleryimport.go index 341232a1..5f8d1944 100644 --- a/backend/app/http/endpoints/api/gallery/galleryimport.go +++ b/backend/app/http/endpoints/api/gallery/galleryimport.go @@ -85,14 +85,14 @@ func ImportHandler(ctx *gin.Context) { } if premiumTier == premium.None { - panels, err := dbclient.Client.Panel.GetByGuild(ctx, guildId) + panelCount, err := dbclient.Client.Panel.GetPanelCount(ctx, guildId) if err != nil { _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch existing panels")) return } - if len(panels) >= freePanelLimit { - ctx.JSON(http.StatusPaymentRequired, utils.ErrorStr("Panel quota exceeded: You have %d/%d panels. Purchase premium to unlock more panels.", len(panels), freePanelLimit)) + if panelCount >= freePanelLimit { + ctx.JSON(http.StatusPaymentRequired, utils.ErrorStr("Panel quota exceeded: You have %d/%d panels. Purchase premium to unlock more panels.", panelCount, freePanelLimit)) return } } @@ -141,9 +141,12 @@ func ImportHandler(ctx *gin.Context) { buttonStyle = int(*listing.ButtonStyle) } + panelName := listing.Name + panel := database.Panel{ ChannelId: 0, // Will be set if we send a message GuildId: guildId, + Name: &panelName, Title: listing.Title, Content: listing.Content, Colour: listing.Colour, diff --git a/backend/app/http/endpoints/api/gallery/resubmit.go b/backend/app/http/endpoints/api/gallery/resubmit.go index cd118483..5ae3afaa 100644 --- a/backend/app/http/endpoints/api/gallery/resubmit.go +++ b/backend/app/http/endpoints/api/gallery/resubmit.go @@ -135,6 +135,11 @@ func ResubmitHandler(ctx *gin.Context) { return } + if panel.MessageUsesComponentsV2 || panel.WelcomeMessageUsesComponentsV2 { + ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Panels that use Components v2 cannot be submitted to the gallery. Switch the panel's messages back to Classic first.")) + return + } + if panel.EmojiId != nil { ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Panels with custom Discord emojis cannot be submitted to the gallery. Please use a standard Unicode emoji instead.")) return @@ -294,9 +299,9 @@ func ResubmitHandler(ctx *gin.Context) { ResourceType: database.AuditResourceGalleryListing, ResourceId: audit.StringPtr(strconv.Itoa(listingId)), NewData: map[string]any{ - "name": body.Name, - "category": body.Category, - "resubmit": true, + "name": body.Name, + "category": body.Category, + "resubmit": true, }, }) diff --git a/backend/app/http/endpoints/api/gallery/submit.go b/backend/app/http/endpoints/api/gallery/submit.go index 2b06b0c2..b174b920 100644 --- a/backend/app/http/endpoints/api/gallery/submit.go +++ b/backend/app/http/endpoints/api/gallery/submit.go @@ -100,6 +100,11 @@ func SubmitHandler(ctx *gin.Context) { return } + if panel.MessageUsesComponentsV2 || panel.WelcomeMessageUsesComponentsV2 { + ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Panels that use Components v2 cannot be submitted to the gallery. Switch the panel's messages back to Classic first.")) + return + } + // Validate emoji is unicode only (no custom emoji IDs) if panel.EmojiId != nil { ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Panels with custom Discord emojis cannot be submitted to the gallery. Please use a standard Unicode emoji instead.")) @@ -136,21 +141,21 @@ func SubmitHandler(ctx *gin.Context) { // Snapshot the panel design fields into a gallery listing listing := database.GalleryListing{ - SubmitterUserId: userId, - SourceGuildId: guildId, - Name: body.Name, - Description: body.Description, - Category: body.Category, - Status: database.GalleryListingStatusPending, - Title: panel.Title, - Content: panel.Content, - Colour: panel.Colour, - ImageUrl: panel.ImageUrl, - ThumbnailUrl: panel.ThumbnailUrl, - ButtonStyle: intToInt16Ptr(panel.ButtonStyle), - ButtonLabel: panel.ButtonLabel, - EmojiName: panel.EmojiName, - WelcomeMessage: welcomeMessageJSON, + SubmitterUserId: userId, + SourceGuildId: guildId, + Name: body.Name, + Description: body.Description, + Category: body.Category, + Status: database.GalleryListingStatusPending, + Title: panel.Title, + Content: panel.Content, + Colour: panel.Colour, + ImageUrl: panel.ImageUrl, + ThumbnailUrl: panel.ThumbnailUrl, + ButtonStyle: intToInt16Ptr(panel.ButtonStyle), + ButtonLabel: panel.ButtonLabel, + EmojiName: panel.EmojiName, + WelcomeMessage: welcomeMessageJSON, } listingId, err := dbclient.Client.GalleryListings.Create(ctx, listing) diff --git a/backend/app/http/endpoints/api/gallery/types.go b/backend/app/http/endpoints/api/gallery/types.go index 9eeb4815..eaa71e3a 100644 --- a/backend/app/http/endpoints/api/gallery/types.go +++ b/backend/app/http/endpoints/api/gallery/types.go @@ -59,56 +59,56 @@ func resolveUsersBatch(ctx context.Context, userIds []uint64) map[uint64]submitt // galleryListingPublicResponse is the public API response that omits sensitive fields // such as source guild ID and review notes. type galleryListingPublicResponse struct { - Id int `json:"id"` - ListingType string `json:"listing_type"` - SubmittedUser submittedUser `json:"submitted_user"` - Name string `json:"name"` - Description string `json:"description"` - Category string `json:"category"` - ImportCount int `json:"import_count"` - Featured bool `json:"featured"` - SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` - Title string `json:"title"` - Content string `json:"content"` - Colour *int32 `json:"colour"` - ImageUrl *string `json:"image_url,omitempty"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - ButtonStyle *int16 `json:"button_style"` - ButtonLabel string `json:"button_label"` - EmojiName *string `json:"emoji_name,omitempty"` - WelcomeMessage stdjson.RawMessage `json:"welcome_message,omitempty"` - Tags []string `json:"tags"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id int `json:"id"` + ListingType string `json:"listing_type"` + SubmittedUser submittedUser `json:"submitted_user"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + ImportCount int `json:"import_count"` + Featured bool `json:"featured"` + SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Colour *int32 `json:"colour"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name,omitempty"` + WelcomeMessage stdjson.RawMessage `json:"welcome_message,omitempty"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // galleryListingResponse is the full API response including sensitive fields, // used only for guild-scoped and admin endpoints. type galleryListingResponse struct { - Id int `json:"id"` - ListingType string `json:"listing_type"` - SubmittedUser submittedUser `json:"submitted_user"` - SourceGuildId uint64 `json:"source_guild_id,string"` - Name string `json:"name"` - Description string `json:"description"` - Category string `json:"category"` - Status string `json:"status"` - ReviewNote *string `json:"review_note,omitempty"` - ImportCount int `json:"import_count"` - Featured bool `json:"featured"` - SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` - Title string `json:"title"` - Content string `json:"content"` - Colour *int32 `json:"colour"` - ImageUrl *string `json:"image_url,omitempty"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - ButtonStyle *int16 `json:"button_style"` - ButtonLabel string `json:"button_label"` - EmojiName *string `json:"emoji_name,omitempty"` - WelcomeMessage stdjson.RawMessage `json:"welcome_message,omitempty"` - Tags []string `json:"tags"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id int `json:"id"` + ListingType string `json:"listing_type"` + SubmittedUser submittedUser `json:"submitted_user"` + SourceGuildId uint64 `json:"source_guild_id,string"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Status string `json:"status"` + ReviewNote *string `json:"review_note,omitempty"` + ImportCount int `json:"import_count"` + Featured bool `json:"featured"` + SnapshotData stdjson.RawMessage `json:"snapshot_data,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Colour *int32 `json:"colour"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name,omitempty"` + WelcomeMessage stdjson.RawMessage `json:"welcome_message,omitempty"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // stripWelcomeMessageGuildId removes the guild_id field from a welcome message JSON blob. @@ -139,27 +139,27 @@ func toPublicListingResponse(l database.GalleryListing, tags []string, user subm } return galleryListingPublicResponse{ - Id: l.Id, - ListingType: listingType, - SubmittedUser: user, - Name: l.Name, - Description: l.Description, - Category: l.Category, - ImportCount: l.ImportCount, - Featured: l.Featured, - SnapshotData: stdjson.RawMessage(l.SnapshotData), - Title: l.Title, - Content: l.Content, - Colour: l.Colour, - ImageUrl: l.ImageUrl, - ThumbnailUrl: l.ThumbnailUrl, - ButtonStyle: l.ButtonStyle, - ButtonLabel: l.ButtonLabel, - EmojiName: l.EmojiName, - WelcomeMessage: stripWelcomeMessageGuildId(l.WelcomeMessage), - Tags: tags, - CreatedAt: l.CreatedAt, - UpdatedAt: l.UpdatedAt, + Id: l.Id, + ListingType: listingType, + SubmittedUser: user, + Name: l.Name, + Description: l.Description, + Category: l.Category, + ImportCount: l.ImportCount, + Featured: l.Featured, + SnapshotData: stdjson.RawMessage(l.SnapshotData), + Title: l.Title, + Content: l.Content, + Colour: l.Colour, + ImageUrl: l.ImageUrl, + ThumbnailUrl: l.ThumbnailUrl, + ButtonStyle: l.ButtonStyle, + ButtonLabel: l.ButtonLabel, + EmojiName: l.EmojiName, + WelcomeMessage: stripWelcomeMessageGuildId(l.WelcomeMessage), + Tags: tags, + CreatedAt: l.CreatedAt, + UpdatedAt: l.UpdatedAt, } } @@ -170,29 +170,29 @@ func toListingResponse(l database.GalleryListing, tags []string, user submittedU } return galleryListingResponse{ - Id: l.Id, - ListingType: listingType, - SubmittedUser: user, - SourceGuildId: l.SourceGuildId, - Name: l.Name, - Description: l.Description, - Category: l.Category, - Status: string(l.Status), - ReviewNote: l.ReviewNote, - ImportCount: l.ImportCount, - Featured: l.Featured, - SnapshotData: stdjson.RawMessage(l.SnapshotData), - Title: l.Title, - Content: l.Content, - Colour: l.Colour, - ImageUrl: l.ImageUrl, - ThumbnailUrl: l.ThumbnailUrl, - ButtonStyle: l.ButtonStyle, - ButtonLabel: l.ButtonLabel, - EmojiName: l.EmojiName, - WelcomeMessage: stdjson.RawMessage(l.WelcomeMessage), - Tags: tags, - CreatedAt: l.CreatedAt, - UpdatedAt: l.UpdatedAt, + Id: l.Id, + ListingType: listingType, + SubmittedUser: user, + SourceGuildId: l.SourceGuildId, + Name: l.Name, + Description: l.Description, + Category: l.Category, + Status: string(l.Status), + ReviewNote: l.ReviewNote, + ImportCount: l.ImportCount, + Featured: l.Featured, + SnapshotData: stdjson.RawMessage(l.SnapshotData), + Title: l.Title, + Content: l.Content, + Colour: l.Colour, + ImageUrl: l.ImageUrl, + ThumbnailUrl: l.ThumbnailUrl, + ButtonStyle: l.ButtonStyle, + ButtonLabel: l.ButtonLabel, + EmojiName: l.EmojiName, + WelcomeMessage: stdjson.RawMessage(l.WelcomeMessage), + Tags: tags, + CreatedAt: l.CreatedAt, + UpdatedAt: l.UpdatedAt, } } diff --git a/backend/app/http/endpoints/api/panel/bodykeys.go b/backend/app/http/endpoints/api/panel/bodykeys.go new file mode 100644 index 00000000..022c062f --- /dev/null +++ b/backend/app/http/endpoints/api/panel/bodykeys.go @@ -0,0 +1,150 @@ +package api + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/TicketsBot-cloud/database" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" +) + +func bindJSONWithKeys(c *gin.Context, obj any) (map[string]bool, error) { + body, err := c.GetRawData() + if err != nil { + return nil, err + } + + body, err = dropStringTrees(body) + if err != nil { + return nil, err + } + + if err := binding.JSON.BindBody(body, obj); err != nil { + return nil, err + } + + return jsonKeys(body) +} + +// Dashboards from before Components V2 echo the GET response, where trees are JSON strings. +func dropStringTrees(body []byte) ([]byte, error) { + var fields map[string]json.RawMessage + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&fields); err != nil { + return nil, err + } + + dropped := false + for key, value := range fields { + switch strings.ToLower(key) { + case "message_components", "welcome_message_components", "components": + if len(value) > 0 && value[0] == '"' { + delete(fields, key) + dropped = true + } + } + } + + if !dropped { + return body, nil + } + + return json.Marshal(fields) +} + +func bindPanelCreateBody(c *gin.Context, data *panelBody) error { + keys, err := bindJSONWithKeys(c, data) + if err != nil { + return err + } + + if !keys["message_components"] { + data.MessageUsesComponentsV2 = false + } + + if !keys["welcome_message_components"] { + data.WelcomeMessageUsesComponentsV2 = false + } + + return nil +} + +// Lower-cased because binding matches field names case-insensitively. +func jsonKeys(body []byte) (map[string]bool, error) { + var fields map[string]json.RawMessage + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&fields); err != nil { + return nil, err + } + + keys := make(map[string]bool, len(fields)) + for key := range fields { + keys[strings.ToLower(key)] = true + } + + return keys, nil +} + +func (p *panelBody) keepStoredWhenAbsent(keys map[string]bool, stored database.Panel) error { + if !keys["message_uses_components_v2"] { + p.MessageUsesComponentsV2 = stored.MessageUsesComponentsV2 + } + + if !keys["message_components"] && p.MessageUsesComponentsV2 { + components, err := unmarshalComponents(stored.MessageComponents) + if err != nil { + return err + } + + p.MessageComponents = components + } + + if !keys["welcome_message_uses_components_v2"] { + p.WelcomeMessageUsesComponentsV2 = stored.WelcomeMessageUsesComponentsV2 + } + + if !keys["welcome_message_components"] && p.WelcomeMessageUsesComponentsV2 { + components, err := unmarshalComponents(stored.WelcomeMessageComponents) + if err != nil { + return err + } + + p.WelcomeMessageComponents = components + } + + if !keys["name"] { + p.Name = stored.Name + } + + return nil +} + +func (p *panelBody) mergeStored(keys map[string]bool, existing database.Panel) error { + if err := p.keepStoredWhenAbsent(keys, existing); err != nil { + return err + } + + p.retainClassicFields(existing, keys["colour"]) + return nil +} + +func (d *multiPanelCreateData) keepStoredWhenAbsent(keys map[string]bool, stored database.MultiPanel) error { + if !keys["uses_components_v2"] { + d.UsesComponentsV2 = stored.UsesComponentsV2 + } + + if !keys["components"] && d.UsesComponentsV2 { + components, err := unmarshalComponents(stored.Components) + if err != nil { + return err + } + + d.Components = components + } + + if !keys["name"] && stored.Name != nil { + d.Name = *stored.Name + } + + return nil +} diff --git a/backend/app/http/endpoints/api/panel/bodykeys_test.go b/backend/app/http/endpoints/api/panel/bodykeys_test.go new file mode 100644 index 00000000..1a2ef418 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/bodykeys_test.go @@ -0,0 +1,382 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/utils" +) + +func TestJsonKeys(t *testing.T) { + tests := []struct { + name string + body string + want map[string]bool + wantErr bool + }{ + {name: "top-level keys only", body: `{"name":"x","welcome_message":{"title":"y"}}`, want: map[string]bool{"name": true, "welcome_message": true}}, + {name: "null counts as present", body: `{"name":null,"message_components":null}`, want: map[string]bool{"name": true, "message_components": true}}, + {name: "keys match case-insensitively", body: `{"Name":"x","MESSAGE_USES_COMPONENTS_V2":true}`, want: map[string]bool{"name": true, "message_uses_components_v2": true}}, + {name: "only the first value is read, as binding does", body: `{"name":"x"} {"title":"y"}`, want: map[string]bool{"name": true}}, + {name: "null body has no keys", body: `null`, want: map[string]bool{}}, + {name: "malformed", body: `{"name":`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := jsonKeys([]byte(tt.body)) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func bindTestBody(t *testing.T, body string, obj any) (map[string]bool, error) { + t.Helper() + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(body)) + + return bindJSONWithKeys(c, obj) +} + +func TestBindJSONWithKeys(t *testing.T) { + var data panelBody + keys, err := bindTestBody(t, `{"title":"Help","name":null}`, &data) + require.NoError(t, err) + require.Equal(t, "Help", data.Title) + require.Nil(t, data.Name) + require.Equal(t, map[string]bool{"title": true, "name": true}, keys) + + _, err = bindTestBody(t, `{"title":`, &data) + require.Error(t, err) +} + +func TestPanelKeepStoredWhenAbsent(t *testing.T) { + messageTree := `[{"type":10,"content":"message"}]` + welcomeTree := `[{"type":10,"content":"welcome"}]` + stored := database.Panel{ + MessageUsesComponentsV2: true, + MessageComponents: &messageTree, + WelcomeMessageUsesComponentsV2: true, + WelcomeMessageComponents: &welcomeTree, + Name: strPtr("Billing"), + } + + t.Run("absent keys keep the stored values", func(t *testing.T) { + data := panelBody{Title: "Help"} + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{"title": true}, stored)) + + require.Equal(t, "Help", data.Title) + require.True(t, data.MessageUsesComponentsV2) + require.True(t, data.WelcomeMessageUsesComponentsV2) + require.Equal(t, stored.Name, data.Name) + + got, err := marshalComponents(data.MessageComponents) + require.NoError(t, err) + require.JSONEq(t, messageTree, *got) + + got, err = marshalComponents(data.WelcomeMessageComponents) + require.NoError(t, err) + require.JSONEq(t, welcomeTree, *got) + }) + + t.Run("present keys keep the request's values", func(t *testing.T) { + data := panelBody{WelcomeMessageComponents: texts(1)} + keys := map[string]bool{ + "message_uses_components_v2": true, + "message_components": true, + "welcome_message_uses_components_v2": true, + "welcome_message_components": true, + "name": true, + } + + require.NoError(t, data.keepStoredWhenAbsent(keys, stored)) + require.Equal(t, panelBody{WelcomeMessageComponents: texts(1)}, data) + }) + + t.Run("each key is decided on its own", func(t *testing.T) { + data := panelBody{MessageComponents: texts(2), WelcomeMessageUsesComponentsV2: false} + keys := map[string]bool{"message_components": true, "welcome_message_uses_components_v2": true} + + require.NoError(t, data.keepStoredWhenAbsent(keys, stored)) + require.True(t, data.MessageUsesComponentsV2) + require.Len(t, data.MessageComponents, 2) + require.False(t, data.WelcomeMessageUsesComponentsV2) + require.Nil(t, data.WelcomeMessageComponents) + require.Equal(t, stored.Name, data.Name) + }) + + t.Run("nothing stored stays classic and unnamed", func(t *testing.T) { + var data panelBody + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{}, database.Panel{})) + require.Equal(t, panelBody{}, data) + }) + + t.Run("corrupt stored V2 tree", func(t *testing.T) { + corrupt := database.Panel{MessageUsesComponentsV2: true, MessageComponents: strPtr("{not json")} + + var data panelBody + require.Error(t, data.keepStoredWhenAbsent(map[string]bool{}, corrupt)) + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{"message_components": true}, corrupt)) + }) + + t.Run("a classic message never parses its retained tree", func(t *testing.T) { + corrupt := database.Panel{MessageComponents: strPtr("{not json"), WelcomeMessageComponents: strPtr("{not json")} + + var data panelBody + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{}, corrupt)) + require.Nil(t, data.MessageComponents) + require.Nil(t, data.WelcomeMessageComponents) + }) +} + +func TestMultiPanelKeepStoredWhenAbsent(t *testing.T) { + tree := `[{"type":10,"content":"stored"}]` + stored := database.MultiPanel{UsesComponentsV2: true, Components: &tree, Name: strPtr("Help desk")} + + t.Run("absent keys keep the stored values", func(t *testing.T) { + data := multiPanelCreateData{SelectMenu: true} + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{"select_menu": true}, stored)) + + require.True(t, data.SelectMenu) + require.True(t, data.UsesComponentsV2) + require.Equal(t, "Help desk", data.Name) + + got, err := marshalComponents(data.Components) + require.NoError(t, err) + require.JSONEq(t, tree, *got) + }) + + t.Run("present keys keep the request's values", func(t *testing.T) { + var data multiPanelCreateData + keys := map[string]bool{"uses_components_v2": true, "components": true, "name": true} + + require.NoError(t, data.keepStoredWhenAbsent(keys, stored)) + require.Equal(t, multiPanelCreateData{}, data) + }) + + t.Run("an absent name with nothing stored stays blank", func(t *testing.T) { + var data multiPanelCreateData + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{}, database.MultiPanel{})) + require.Equal(t, multiPanelCreateData{}, data) + }) + + t.Run("corrupt stored V2 tree", func(t *testing.T) { + corrupt := database.MultiPanel{UsesComponentsV2: true, Components: strPtr(`{"a":1}`)} + + var data multiPanelCreateData + require.Error(t, data.keepStoredWhenAbsent(map[string]bool{}, corrupt)) + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{"components": true}, corrupt)) + }) + + t.Run("a classic message never parses its retained tree", func(t *testing.T) { + corrupt := database.MultiPanel{Components: strPtr(`{"a":1}`)} + + var data multiPanelCreateData + require.NoError(t, data.keepStoredWhenAbsent(map[string]bool{}, corrupt)) + require.Nil(t, data.Components) + }) +} + +func TestOldClientBodyKeepsStoredComponentsV2AndName(t *testing.T) { + tree := `[{"type":10,"content":"stored"}]` + + t.Run("panel", func(t *testing.T) { + stored := database.Panel{MessageUsesComponentsV2: true, MessageComponents: &tree, Name: strPtr("Billing")} + + var data panelBody + keys, err := bindTestBody(t, `{"title":"Help","content":"Open a ticket","channel_id":"1","welcome_message":null}`, &data) + require.NoError(t, err) + require.NoError(t, data.mergeStored(keys, stored)) + + require.True(t, data.MessageUsesComponentsV2) + require.Len(t, data.MessageComponents, 1) + require.Equal(t, strPtr("Billing"), data.trimmedName()) + require.False(t, data.entersComponentsV2(stored)) + }) + + t.Run("multi-panel", func(t *testing.T) { + stored := database.MultiPanel{UsesComponentsV2: true, Components: &tree, Name: strPtr("Help desk")} + + var data multiPanelCreateData + keys, err := bindTestBody(t, `{"channel_id":"1","select_menu":false,"panels":[{"panel_id":1},{"panel_id":2}],"embed":{"title":"Pick"}}`, &data) + require.NoError(t, err) + require.NoError(t, data.keepStoredWhenAbsent(keys, stored)) + + require.True(t, data.UsesComponentsV2) + require.Len(t, data.Components, 1) + + name, err := normaliseMultiPanelName(data.Name) + require.NoError(t, err) + require.Equal(t, strPtr("Help desk"), name) + require.False(t, data.entersComponentsV2(stored)) + }) +} + +func echoedGetBody(t *testing.T, stored database.Panel) string { + t.Helper() + + raw, err := json.Marshal(stored) + require.NoError(t, err) + + var echo map[string]any + require.NoError(t, json.Unmarshal(raw, &echo)) + for _, key := range []string{"panel_id", "guild_id", "welcome_message_embed", "force_disabled", "emoji_name", "emoji_id", "emoji_animated"} { + delete(echo, key) + } + require.IsType(t, "", echo["message_components"]) + + body, err := json.Marshal(echo) + require.NoError(t, err) + + return string(body) +} + +func TestEchoedGetBodyKeepsStoredTrees(t *testing.T) { + tree := `[{"type":10,"content":"stored"}]` + + t.Run("panel", func(t *testing.T) { + stored := database.Panel{PanelId: 1, Title: "Help", MessageUsesComponentsV2: true, MessageComponents: &tree, WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: &tree, Name: strPtr("Billing")} + + var data panelBody + keys, err := bindTestBody(t, echoedGetBody(t, stored), &data) + require.NoError(t, err) + require.False(t, keys["message_components"]) + require.False(t, keys["welcome_message_components"]) + require.True(t, keys["message_uses_components_v2"]) + require.True(t, keys["name"]) + + require.NoError(t, data.mergeStored(keys, stored)) + require.True(t, data.MessageUsesComponentsV2) + require.Len(t, data.MessageComponents, 1) + require.Len(t, data.WelcomeMessageComponents, 1) + require.Equal(t, strPtr("Billing"), data.trimmedName()) + require.False(t, data.entersComponentsV2(stored)) + }) + + t.Run("multi-panel", func(t *testing.T) { + stored := database.MultiPanel{UsesComponentsV2: true, Components: &tree, Name: strPtr("Help desk")} + body := `{"id":3,"name":"Help desk","channel_id":"1","guild_id":"2","message_id":"4","select_menu":false,"panels":[{"panel_id":1},{"panel_id":2}],"embed":{"title":"Pick"},"uses_components_v2":true,"components":"[{\"type\":10,\"content\":\"stored\"}]"}` + + var data multiPanelCreateData + keys, err := bindTestBody(t, body, &data) + require.NoError(t, err) + require.False(t, keys["components"]) + + require.NoError(t, data.keepStoredWhenAbsent(keys, stored)) + require.True(t, data.UsesComponentsV2) + require.Len(t, data.Components, 1) + require.Equal(t, "Help desk", data.Name) + require.False(t, data.entersComponentsV2(stored)) + }) + + t.Run("array trees and other bodies bind as before", func(t *testing.T) { + var data panelBody + keys, err := bindTestBody(t, `{"message_components":[{"type":10,"content":"new"}],"Title":"x"}`, &data) + require.NoError(t, err) + require.True(t, keys["message_components"]) + require.Len(t, data.MessageComponents, 1) + require.Equal(t, "x", data.Title) + + _, err = bindTestBody(t, `{"message_components":`, &data) + require.Error(t, err) + + _, err = bindTestBody(t, `[]`, &data) + require.Error(t, err) + }) +} + +func TestPanelMergeStored(t *testing.T) { + tree := `[{"type":10,"content":"stored"}]` + stored := database.Panel{MessageUsesComponentsV2: true, MessageComponents: &tree, Content: "Stored content", Colour: utils.Ptr[int32](0x123456)} + + tests := []struct { + name string + body string + want *uint32 + }{ + {name: "an omitted colour keeps the stored one", body: `{"title":"Help"}`, want: utils.Ptr[uint32](0x123456)}, + {name: "a null colour clears it", body: `{"title":"Help","colour":null}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var data panelBody + keys, err := bindTestBody(t, tt.body, &data) + require.NoError(t, err) + + require.NoError(t, data.mergeStored(keys, stored)) + require.True(t, data.MessageUsesComponentsV2) + require.Equal(t, "Stored content", data.Content) + require.Equal(t, tt.want, data.Colour) + }) + } + + t.Run("a corrupt stored V2 tree fails the merge", func(t *testing.T) { + corrupt := database.Panel{MessageUsesComponentsV2: true, MessageComponents: strPtr("{not json")} + + var data panelBody + require.Error(t, data.mergeStored(map[string]bool{}, corrupt)) + }) +} + +func TestBindPanelCreateBody(t *testing.T) { + bind := func(t *testing.T, body string) (panelBody, error) { + t.Helper() + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + + var data panelBody + err := bindPanelCreateBody(c, &data) + return data, err + } + + t.Run("an echoed V2 source is created Classic", func(t *testing.T) { + tree := `[{"type":10,"content":"stored"}]` + source := database.Panel{PanelId: 1, Title: "Help", MessageUsesComponentsV2: true, MessageComponents: &tree, WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: &tree, Name: strPtr("Billing")} + + data, err := bind(t, echoedGetBody(t, source)) + require.NoError(t, err) + require.False(t, data.MessageUsesComponentsV2) + require.False(t, data.WelcomeMessageUsesComponentsV2) + require.Nil(t, data.MessageComponents) + require.Nil(t, data.WelcomeMessageComponents) + require.Equal(t, "Help", data.Title) + }) + + t.Run("array trees keep V2", func(t *testing.T) { + data, err := bind(t, `{"message_uses_components_v2":true,"message_components":[{"type":10,"content":"new"}],"welcome_message_uses_components_v2":true,"welcome_message_components":[{"type":10,"content":"welcome"}]}`) + require.NoError(t, err) + require.True(t, data.MessageUsesComponentsV2) + require.True(t, data.WelcomeMessageUsesComponentsV2) + require.Len(t, data.MessageComponents, 1) + require.Len(t, data.WelcomeMessageComponents, 1) + }) + + t.Run("each message is decided on its own", func(t *testing.T) { + data, err := bind(t, `{"message_uses_components_v2":true,"message_components":[{"type":10,"content":"new"}],"welcome_message_uses_components_v2":true}`) + require.NoError(t, err) + require.True(t, data.MessageUsesComponentsV2) + require.False(t, data.WelcomeMessageUsesComponentsV2) + }) + + t.Run("malformed JSON fails", func(t *testing.T) { + _, err := bind(t, `{"title":`) + require.Error(t, err) + }) +} diff --git a/backend/app/http/endpoints/api/panel/componentvalidation.go b/backend/app/http/endpoints/api/panel/componentvalidation.go new file mode 100644 index 00000000..4f628fe8 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/componentvalidation.go @@ -0,0 +1,507 @@ +package api + +import ( + "net/url" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" + "github.com/ticketsbot-cloud/dashboard/backend/utils/types" +) + +const ( + maxAccentColour = 0xFFFFFF + + maxMessageComponents = 40 + + // Not in Discord's docs: observed to count only text display content (section text included). + maxTreeTextLength = 4000 + maxComponentUrlLength = 512 + maxMediaDescriptionLength = 1024 + maxButtonLabelLength = 80 + maxActionRowChildren = 5 + maxSectionTextDisplays = 3 + maxGalleryItems = 10 + + // Marks a panel placed in a multi-panel tree; replaced by the real panel button before sending. + panelPlacementPrefix = "panel:" + + // A new panel has no id yet. + ownPanelCustomId = panelPlacementPrefix + "0" + + panelDropdownCustomId = "multipanel" + + panelMessageReserved = 2 + welcomeFormAnswersReserved = 6 + dropdownReserved = 2 +) + +type treeRules struct { + validPanelIds map[int]bool + dropdownMode bool + ownPanel bool + reserved int + allowAvatarPlaceholder bool +} + +type treeValidator struct { + rules treeRules + count int + textLength int + placed map[int]bool + selectPlaced bool + ownPlaced bool +} + +type treeResult struct { + placed map[int]bool + dropdownUsed bool + ownPlaced bool + count int +} + +func validateComponentTree(tree []component.Component, rules treeRules) (treeResult, error) { + v := &treeValidator{rules: rules, placed: make(map[int]bool)} + + for _, c := range tree { + if err := v.node(c, false); err != nil { + return treeResult{}, err + } + } + + if v.textLength > maxTreeTextLength { + return treeResult{}, validation.NewInvalidInputErrorf("Text displays contain %d characters in total (maximum %d)", v.textLength, maxTreeTextLength) + } + + return treeResult{placed: v.placed, dropdownUsed: v.selectPlaced, ownPlaced: v.ownPlaced, count: v.count}, nil +} + +func checkComponentBudget(count, reserved int) error { + limit := maxMessageComponents - reserved + if count <= limit { + return nil + } + + if reserved > 0 { + return validation.NewInvalidInputErrorf("Too many components: %d (maximum %d, with %d reserved for components added automatically)", count, limit, reserved) + } + + return validation.NewInvalidInputErrorf("Too many components: %d (maximum %d)", count, limit) +} + +func (v *treeValidator) add(n int) error { + v.count += n + return checkComponentBudget(v.count, v.rules.reserved) +} + +func (v *treeValidator) node(c component.Component, inContainer bool) error { + if err := v.add(1); err != nil { + return err + } + + switch d := c.ComponentData.(type) { + case component.Container: + if inContainer { + return validation.NewInvalidInputError("Container: cannot be placed inside another container") + } + + if len(d.Components) == 0 { + return validation.NewInvalidInputError("Container: cannot be empty") + } + + if d.AccentColor != nil && (*d.AccentColor < 0 || *d.AccentColor > maxAccentColour) { + return validation.NewInvalidInputErrorf("Container: the accent colour must be between 0 and %d", maxAccentColour) + } + + for _, child := range d.Components { + if err := v.node(child, true); err != nil { + return err + } + } + + return nil + case component.Section: + return v.section(d) + case component.TextDisplay: + return v.textDisplay(d) + case component.MediaGallery: + return v.mediaGallery(d) + case component.Separator: + if d.Spacing != nil && *d.Spacing != 1 && *d.Spacing != 2 { + return validation.NewInvalidInputError("Separator: the spacing must be small (1) or large (2)") + } + + return nil + case component.ActionRow: + return v.actionRow(d) + case component.Thumbnail: + return validation.NewInvalidInputError("Thumbnail: can only be used as a section's accessory") + case component.File: + return validation.NewInvalidInputError("File: not supported") + case component.Button, component.SelectMenu: + return validation.NewInvalidInputError("Buttons and dropdowns must be placed inside an action row") + default: + return validation.NewInvalidInputError("This message contains a component type that is not allowed") + } +} + +func (v *treeValidator) section(s component.Section) error { + if len(s.Components) < 1 || len(s.Components) > maxSectionTextDisplays { + return validation.NewInvalidInputErrorf("Section: needs between 1 and %d text displays", maxSectionTextDisplays) + } + + for _, child := range s.Components { + td, ok := child.ComponentData.(component.TextDisplay) + if !ok { + return validation.NewInvalidInputError("Section: can only contain text displays") + } + + if err := v.add(1); err != nil { + return err + } + + if err := v.textDisplay(td); err != nil { + return err + } + } + + if err := v.add(1); err != nil { + return err + } + + switch accessory := s.Accessory.ComponentData.(type) { + case component.Thumbnail: + return v.thumbnail(accessory) + case component.Button: + return v.button(accessory) + case nil: + return validation.NewInvalidInputError("Section: needs a thumbnail or button accessory") + default: + return validation.NewInvalidInputError("Section: the accessory must be a thumbnail or a button") + } +} + +func (v *treeValidator) textDisplay(t component.TextDisplay) error { + if strings.TrimSpace(t.Content) == "" { + return validation.NewInvalidInputError("Text display: cannot be blank") + } + + if strings.ContainsRune(t.Content, 0) { + return validation.NewInvalidInputError("Text display: cannot contain null characters") + } + + v.textLength += utf8.RuneCountInString(t.Content) + return nil +} + +func (v *treeValidator) thumbnail(t component.Thumbnail) error { + if err := v.checkMediaUrl("Thumbnail", t.Media.Url); err != nil { + return err + } + + return checkMediaDescription("Thumbnail", t.Description) +} + +func (v *treeValidator) mediaGallery(g component.MediaGallery) error { + if len(g.Items) < 1 || len(g.Items) > maxGalleryItems { + return validation.NewInvalidInputErrorf("Media gallery: needs between 1 and %d items", maxGalleryItems) + } + + for _, item := range g.Items { + if err := v.checkMediaUrl("Media gallery", item.Media.Url); err != nil { + return err + } + + if err := checkMediaDescription("Media gallery", item.Description); err != nil { + return err + } + } + + return nil +} + +func (v *treeValidator) actionRow(row component.ActionRow) error { + if len(row.Components) == 0 { + return validation.NewInvalidInputError("Action row: cannot be empty") + } + + if len(row.Components) > maxActionRowChildren { + return validation.NewInvalidInputErrorf("Action row: cannot contain more than %d buttons", maxActionRowChildren) + } + + if err := v.add(len(row.Components)); err != nil { + return err + } + + for _, child := range row.Components { + switch d := child.ComponentData.(type) { + case component.Button: + if err := v.button(d); err != nil { + return err + } + case component.SelectMenu: + if len(row.Components) != 1 { + return validation.NewInvalidInputError("Action row: the panel dropdown must be alone in its row") + } + + if err := v.dropdown(d); err != nil { + return err + } + default: + return validation.NewInvalidInputError("Action row: can only contain link buttons, panel buttons or the panel dropdown") + } + } + + return nil +} + +func (v *treeValidator) button(b component.Button) error { + if b.Url != nil && b.CustomId != "" { + return validation.NewInvalidInputError("Button: cannot be both a link button and a panel button") + } + + if b.Url != nil || b.Style == component.ButtonStyleLink { + return v.linkButton(b) + } + + if b.CustomId == ownPanelCustomId && v.rules.ownPanel { + if v.ownPlaced { + return validation.NewInvalidInputError("Panel button: can only be placed once") + } + + v.ownPlaced = true + return nil + } + + panelId, ok := parsePanelPlacement(b.CustomId) + if !ok { + return validation.NewInvalidInputError("Button: must be a link button or a panel button") + } + + if v.rules.validPanelIds == nil { + return validation.NewInvalidInputError("Panel button: can only be placed in a multi-panel") + } + + if !v.rules.validPanelIds[panelId] { + return validation.NewInvalidInputErrorf("Panel button: panel %d is not part of this multi-panel", panelId) + } + + if v.placed[panelId] { + return validation.NewInvalidInputErrorf("Panel button: panel %d is placed more than once", panelId) + } + + v.placed[panelId] = true + return nil +} + +func (v *treeValidator) linkButton(b component.Button) error { + if b.Style != component.ButtonStyleLink || b.Url == nil { + return validation.NewInvalidInputError("Link button: needs the link style and a URL") + } + + if b.SkuId != nil { + return validation.NewInvalidInputError("Link button: cannot have a SKU") + } + + if b.Emoji != nil { + return validation.NewInvalidInputError("Link button: cannot have an emoji") + } + + if err := checkUrl("Link button", *b.Url); err != nil { + return err + } + + label := strings.TrimSpace(b.Label) + if label == "" { + return validation.NewInvalidInputError("Link button: the label cannot be blank") + } + + if utf8.RuneCountInString(b.Label) > maxButtonLabelLength { + return validation.NewInvalidInputErrorf("Link button: the label must be %d characters or fewer", maxButtonLabelLength) + } + + if strings.ContainsRune(b.Label, 0) { + return validation.NewInvalidInputError("Link button: the label cannot contain null characters") + } + + return nil +} + +func (v *treeValidator) dropdown(s component.SelectMenu) error { + if s.CustomId != panelDropdownCustomId { + return validation.NewInvalidInputError("Dropdown: only the panel dropdown can be placed in a message") + } + + if v.rules.validPanelIds == nil || !v.rules.dropdownMode { + return validation.NewInvalidInputError("Dropdown: can only be placed in a multi-panel that uses a dropdown") + } + + if v.selectPlaced { + return validation.NewInvalidInputError("Dropdown: can only be placed once") + } + + v.selectPlaced = true + return nil +} + +func (v *treeValidator) checkMediaUrl(kind, raw string) error { + if raw == "" { + return validation.NewInvalidInputErrorf("%s: an image URL is required", kind) + } + + if raw == types.AvatarUrlPlaceholder && v.rules.allowAvatarPlaceholder { + return nil + } + + return checkUrl(kind, raw) +} + +func checkMediaDescription(kind string, description *string) error { + if description == nil { + return nil + } + + if utf8.RuneCountInString(*description) > maxMediaDescriptionLength { + return validation.NewInvalidInputErrorf("%s: the alt text must be %d characters or fewer", kind, maxMediaDescriptionLength) + } + + if strings.ContainsRune(*description, 0) { + return validation.NewInvalidInputErrorf("%s: the alt text cannot contain null characters", kind) + } + + return nil +} + +func checkUrl(kind, raw string) error { + if utf8.RuneCountInString(raw) > maxComponentUrlLength { + return validation.NewInvalidInputErrorf("%s: the URL must be %d characters or fewer", kind, maxComponentUrlLength) + } + + for _, r := range raw { + if unicode.IsSpace(r) || unicode.IsControl(r) || isBidiControl(r) { + return validation.NewInvalidInputErrorf("%s: the URL cannot contain spaces or control characters", kind) + } + } + + parsed, err := url.Parse(raw) + if err != nil || !parsed.IsAbs() || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return validation.NewInvalidInputErrorf("%s: the URL must be a full http:// or https:// address", kind) + } + + if parsed.User != nil { + return validation.NewInvalidInputErrorf("%s: the URL cannot contain a username or password", kind) + } + + if !hasDottedHostname(parsed.Hostname()) { + return validation.NewInvalidInputErrorf("%s: the URL needs a valid domain name", kind) + } + + return nil +} + +func hasDottedHostname(host string) bool { + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + if len(labels) < 2 { + return false + } + + for _, label := range labels { + if label == "" { + return false + } + } + + return true +} + +func isBidiControl(r rune) bool { + return unicode.Is(unicode.Bidi_Control, r) +} + +func parsePanelPlacement(customId string) (int, bool) { + raw, ok := strings.CutPrefix(customId, panelPlacementPrefix) + if !ok || raw == "" || len(raw) > 9 { + return 0, false + } + + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 || strconv.Itoa(id) != raw { + return 0, false + } + + return id, true +} + +func normaliseComponentTree(tree []component.Component) []component.Component { + if tree == nil { + return nil + } + + out := make([]component.Component, len(tree)) + for i, c := range tree { + out[i] = normaliseComponent(c) + } + + return out +} + +func normaliseComponent(c component.Component) component.Component { + switch d := c.ComponentData.(type) { + case component.Container: + d.Components = normaliseComponentTree(d.Components) + c.ComponentData = d + case component.Section: + d.Components = normaliseComponentTree(d.Components) + d.Accessory = normaliseComponent(d.Accessory) + c.ComponentData = d + case component.ActionRow: + d.Components = normaliseComponentTree(d.Components) + c.ComponentData = d + case component.Thumbnail: + d.Media = component.UnfurledMediaItem{Url: d.Media.Url} + c.ComponentData = d + case component.MediaGallery: + items := make([]component.MediaGalleryItem, len(d.Items)) + for i, item := range d.Items { + item.Media = component.UnfurledMediaItem{Url: item.Media.Url} + items[i] = item + } + + d.Items = items + c.ComponentData = d + case component.SelectMenu: + c.ComponentData = component.SelectMenu{CustomId: panelDropdownCustomId} + case component.Button: + c.ComponentData = normaliseButton(d) + } + + return c +} + +func normaliseButton(b component.Button) component.Button { + if b.Url != nil { + return component.Button{Label: b.Label, Style: component.ButtonStyleLink, Url: b.Url, Disabled: b.Disabled} + } + + return component.Button{Style: panelButtonStyleOrDefault(b.Style), CustomId: b.CustomId} +} + +func panelButtonStyleOrDefault(style component.ButtonStyle) component.ButtonStyle { + if isPanelButtonStyle(style) { + return style + } + + return component.ButtonStylePrimary +} + +func isPanelButtonStyle(style component.ButtonStyle) bool { + switch style { + case component.ButtonStylePrimary, component.ButtonStyleSecondary, component.ButtonStyleSuccess, component.ButtonStyleDanger: + return true + default: + return false + } +} diff --git a/backend/app/http/endpoints/api/panel/componentvalidation_test.go b/backend/app/http/endpoints/api/panel/componentvalidation_test.go new file mode 100644 index 00000000..ef126b92 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/componentvalidation_test.go @@ -0,0 +1,468 @@ +package api + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" + "github.com/ticketsbot-cloud/dashboard/backend/utils/types" +) + +func strPtr(s string) *string { return &s } + +func text(content string) component.Component { + return component.BuildTextDisplay(component.TextDisplay{Content: content}) +} + +func texts(n int) []component.Component { + out := make([]component.Component, n) + for i := range out { + out[i] = text(fmt.Sprintf("line %d", i)) + } + return out +} + +func linkBtn(label, url string) component.Component { + return component.BuildButton(component.Button{Label: label, Style: component.ButtonStyleLink, Url: strPtr(url)}) +} + +func panelBtn(id int) component.Component { + return component.BuildButton(component.Button{Label: "ignored", Style: component.ButtonStylePrimary, CustomId: fmt.Sprintf("panel:%d", id)}) +} + +func ownPanelBtn() component.Component { + return component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: ownPanelCustomId}) +} + +func dropdownNode() component.Component { + return component.BuildSelectMenu(component.SelectMenu{CustomId: "multipanel", Placeholder: "ignored"}) +} + +func row(children ...component.Component) component.Component { + return component.BuildActionRow(children...) +} + +func container(children ...component.Component) component.Component { + return component.BuildContainer(component.Container{Components: children}) +} + +func section(accessory component.Component, children ...component.Component) component.Component { + return component.BuildSection(component.Section{Components: children, Accessory: accessory}) +} + +func thumb(url string) component.Component { + return component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: url}}) +} + +func gallery(urls ...string) component.Component { + items := make([]component.MediaGalleryItem, len(urls)) + for i, u := range urls { + items[i] = component.MediaGalleryItem{Media: component.UnfurledMediaItem{Url: u}} + } + return component.BuildMediaGallery(component.MediaGallery{Items: items}) +} + +func urlOfLength(n int) string { + prefix := "https://example.com/" + return prefix + strings.Repeat("a", n-len(prefix)) +} + +func intPtr(i int) *int { return &i } + +func containerWithColour(colour int, children ...component.Component) component.Component { + return component.BuildContainer(component.Container{Components: children, AccentColor: &colour}) +} + +func separatorWithSpacing(spacing *int) component.Component { + return component.BuildSeparator(component.Separator{Spacing: spacing}) +} + +var multiPanelRules = treeRules{validPanelIds: map[int]bool{1: true, 2: true, 3: true}, dropdownMode: true} + +var panelMessageRules = treeRules{ownPanel: true} + +func TestValidateComponentTree(t *testing.T) { + longAlt := func(n int) *string { return strPtr(strings.Repeat("a", n)) } + + tests := []struct { + name string + tree []component.Component + rules treeRules + wantErr string + }{ + {name: "empty tree", tree: nil}, + {name: "text display", tree: []component.Component{text("hello")}}, + {name: "blank text display", tree: []component.Component{text(" \n\t")}, wantErr: "Text display"}, + {name: "empty text display", tree: []component.Component{text("")}, wantErr: "Text display"}, + {name: "text display with a null byte", tree: []component.Component{text("a\x00b")}, wantErr: "null characters"}, + {name: "text display with other control characters", tree: []component.Component{text("a\tb\nc\x01")}}, + + {name: "40 components", tree: texts(40)}, + {name: "41 components", tree: texts(41), wantErr: "Too many components"}, + {name: "38 components with 2 reserved", tree: texts(38), rules: treeRules{reserved: 2}}, + {name: "39 components with 2 reserved", tree: texts(39), rules: treeRules{reserved: 2}, wantErr: "2 reserved"}, + {name: "no top level limit below 40", tree: texts(25)}, + {name: "nested components all count", tree: []component.Component{container(texts(20)...), container(texts(19)...)}, wantErr: "Too many components"}, + {name: "nested components exactly at limit", tree: []component.Component{container(texts(20)...), container(texts(18)...)}}, + + {name: "4000 characters of text", tree: []component.Component{text(strings.Repeat("a", 2000)), text(strings.Repeat("b", 2000))}}, + {name: "4001 characters of text", tree: []component.Component{text(strings.Repeat("a", 2000)), text(strings.Repeat("b", 2001))}, wantErr: "4000"}, + {name: "link button labels excluded from the text total", tree: []component.Component{text(strings.Repeat("a", 4000)), row(linkBtn("Go", "https://example.com"))}}, + {name: "section text counts towards the text total", tree: []component.Component{section(thumb("https://example.com/a.png"), text(strings.Repeat("a", 2000))), text(strings.Repeat("b", 2000))}}, + {name: "section text over the text total", tree: []component.Component{section(thumb("https://example.com/a.png"), text(strings.Repeat("a", 2001))), text(strings.Repeat("b", 2000))}, wantErr: "4000"}, + {name: "multibyte characters are counted as characters", tree: []component.Component{text(strings.Repeat("é", 4000))}}, + + {name: "link button url at 512", tree: []component.Component{row(linkBtn("Go", urlOfLength(512)))}}, + {name: "link button url at 513", tree: []component.Component{row(linkBtn("Go", urlOfLength(513)))}, wantErr: "512"}, + {name: "http url", tree: []component.Component{row(linkBtn("Go", "http://example.com"))}}, + {name: "ftp url", tree: []component.Component{row(linkBtn("Go", "ftp://example.com"))}, wantErr: "http"}, + {name: "javascript url", tree: []component.Component{row(linkBtn("Go", "javascript:alert(1)"))}, wantErr: "http"}, + {name: "relative url", tree: []component.Component{row(linkBtn("Go", "/docs"))}, wantErr: "http"}, + {name: "scheme without host", tree: []component.Component{row(linkBtn("Go", "https://"))}, wantErr: "http"}, + {name: "empty url", tree: []component.Component{row(linkBtn("Go", ""))}, wantErr: "Link button"}, + {name: "url with no hostname", tree: []component.Component{row(linkBtn("Go", "https://:80"))}, wantErr: "domain"}, + {name: "url with a lone dot as hostname", tree: []component.Component{row(linkBtn("Go", "https://."))}, wantErr: "domain"}, + {name: "url with a dotless hostname", tree: []component.Component{row(linkBtn("Go", "https://localhost/x"))}, wantErr: "domain"}, + {name: "url with an empty label", tree: []component.Component{row(linkBtn("Go", "https://a..com"))}, wantErr: "domain"}, + {name: "url with a leading dot", tree: []component.Component{row(linkBtn("Go", "https://.com"))}, wantErr: "domain"}, + {name: "url with a trailing dot", tree: []component.Component{row(linkBtn("Go", "https://example.com./x"))}}, + {name: "url with a single slash after the scheme", tree: []component.Component{row(linkBtn("Go", "https:/example.com"))}, wantErr: "http"}, + {name: "url with no slashes after the scheme", tree: []component.Component{row(linkBtn("Go", "https:example.com"))}, wantErr: "http"}, + {name: "media url with a single slash after the scheme", tree: []component.Component{gallery("https:/example.com/a.png")}, wantErr: "Media gallery"}, + {name: "media url with a dotless hostname", tree: []component.Component{gallery("https://localhost/a.png")}, wantErr: "domain"}, + {name: "media url with a space", tree: []component.Component{gallery("https://a.com/a b.png")}, wantErr: "spaces or control"}, + {name: "media url at 513", tree: []component.Component{gallery(urlOfLength(513))}, wantErr: "512"}, + {name: "url with a port", tree: []component.Component{row(linkBtn("Go", "https://example.com:8443/x?y=1#z"))}}, + {name: "url with a space", tree: []component.Component{row(linkBtn("Go", "https://a.com/a b"))}, wantErr: "spaces or control"}, + {name: "url with a tab", tree: []component.Component{row(linkBtn("Go", "https://a.com/a\tb"))}, wantErr: "spaces or control"}, + {name: "url with a newline", tree: []component.Component{row(linkBtn("Go", "https://a.com/\n"))}, wantErr: "spaces or control"}, + {name: "url with a null byte", tree: []component.Component{row(linkBtn("Go", "https://a.com/\x00"))}, wantErr: "spaces or control"}, + {name: "url with a right to left override", tree: []component.Component{row(linkBtn("Go", "https://a.com/\u202Efdp.exe"))}, wantErr: "spaces or control"}, + {name: "url with a bidi isolate", tree: []component.Component{row(linkBtn("Go", "https://a.com/\u2066"))}, wantErr: "spaces or control"}, + {name: "url with a left to right mark", tree: []component.Component{row(linkBtn("Go", "https://a.com/\u200E"))}, wantErr: "spaces or control"}, + {name: "url with a right to left mark", tree: []component.Component{row(linkBtn("Go", "https://a.com/\u200F"))}, wantErr: "spaces or control"}, + {name: "url with an arabic letter mark", tree: []component.Component{row(linkBtn("Go", "https://a.com/\u061C"))}, wantErr: "spaces or control"}, + {name: "url with non bidi unicode", tree: []component.Component{row(linkBtn("Go", "https://a.com/caf\u00e9"))}}, + {name: "url with userinfo", tree: []component.Component{row(linkBtn("Go", "https://user:pw@evil.com/"))}, wantErr: "username"}, + {name: "url with a username only", tree: []component.Component{row(linkBtn("Go", "https://user@evil.com/"))}, wantErr: "username"}, + {name: "media url with userinfo", tree: []component.Component{gallery("https://user:pw@evil.com/a.png")}, wantErr: "Media gallery"}, + + {name: "container without an accent colour", tree: []component.Component{container(text("a"))}}, + {name: "container accent colour at 0", tree: []component.Component{containerWithColour(0, text("a"))}}, + {name: "container accent colour at 0xFFFFFF", tree: []component.Component{containerWithColour(0xFFFFFF, text("a"))}}, + {name: "container accent colour above 0xFFFFFF", tree: []component.Component{containerWithColour(0x1000000, text("a"))}, wantErr: "accent colour"}, + {name: "container accent colour below 0", tree: []component.Component{containerWithColour(-1, text("a"))}, wantErr: "accent colour"}, + {name: "separator without spacing", tree: []component.Component{separatorWithSpacing(nil)}}, + {name: "separator spacing 1", tree: []component.Component{separatorWithSpacing(intPtr(1))}}, + {name: "separator spacing 2", tree: []component.Component{separatorWithSpacing(intPtr(2))}}, + {name: "separator spacing 0", tree: []component.Component{separatorWithSpacing(intPtr(0))}, wantErr: "spacing"}, + {name: "separator spacing 3", tree: []component.Component{separatorWithSpacing(intPtr(3))}, wantErr: "spacing"}, + {name: "separator spacing negative", tree: []component.Component{separatorWithSpacing(intPtr(-1))}, wantErr: "spacing"}, + {name: "link button with an emoji", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStyleLink, Url: strPtr("https://example.com"), Emoji: &emoji.Emoji{Name: "x"}}))}, wantErr: "emoji"}, + + {name: "thumbnail with alt text at 1024", tree: []component.Component{section(component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/a.png"}, Description: longAlt(1024)}), text("hi"))}}, + {name: "thumbnail with alt text at 1025", tree: []component.Component{section(component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/a.png"}, Description: longAlt(1025)}), text("hi"))}, wantErr: "Thumbnail"}, + {name: "thumbnail alt text with a null byte", tree: []component.Component{section(component.BuildThumbnail(component.Thumbnail{Media: component.UnfurledMediaItem{Url: "https://example.com/a.png"}, Description: strPtr("a\x00")}), text("hi"))}, wantErr: "null characters"}, + {name: "thumbnail with bad url", tree: []component.Component{section(thumb("not a url"), text("hi"))}, wantErr: "Thumbnail"}, + {name: "thumbnail with no url", tree: []component.Component{section(thumb(""), text("hi"))}, wantErr: "Thumbnail"}, + {name: "standalone thumbnail", tree: []component.Component{thumb("https://example.com/a.png")}, wantErr: "Thumbnail"}, + {name: "gallery with 1 item", tree: []component.Component{gallery("https://example.com/1.png")}}, + {name: "gallery with 10 items", tree: []component.Component{gallery(strings.Split(strings.Repeat("https://example.com/1.png,", 10), ",")[:10]...)}}, + {name: "gallery with 11 items", tree: []component.Component{gallery(strings.Split(strings.Repeat("https://example.com/1.png,", 11), ",")[:11]...)}, wantErr: "Media gallery"}, + {name: "gallery with 0 items", tree: []component.Component{gallery()}, wantErr: "Media gallery"}, + {name: "gallery with bad scheme", tree: []component.Component{gallery("ftp://example.com/1.png")}, wantErr: "Media gallery"}, + {name: "gallery item alt text at 1024", tree: []component.Component{component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: component.UnfurledMediaItem{Url: "https://example.com/1.png"}, Description: longAlt(1024)}}})}}, + {name: "avatar placeholder in a gallery where allowed", tree: []component.Component{gallery(types.AvatarUrlPlaceholder)}, rules: treeRules{allowAvatarPlaceholder: true}}, + {name: "avatar placeholder in a thumbnail where allowed", tree: []component.Component{section(thumb(types.AvatarUrlPlaceholder), text("hi"))}, rules: treeRules{allowAvatarPlaceholder: true}}, + {name: "avatar placeholder in a gallery by default", tree: []component.Component{gallery(types.AvatarUrlPlaceholder)}, wantErr: "Media gallery"}, + {name: "avatar placeholder in a thumbnail by default", tree: []component.Component{section(thumb(types.AvatarUrlPlaceholder), text("hi"))}, wantErr: "Thumbnail"}, + {name: "avatar placeholder in a link button even where allowed", tree: []component.Component{row(linkBtn("Go", types.AvatarUrlPlaceholder))}, rules: treeRules{allowAvatarPlaceholder: true}, wantErr: "Link button"}, + {name: "other bad media urls with the placeholder allowed", tree: []component.Component{gallery("not a url")}, rules: treeRules{allowAvatarPlaceholder: true}, wantErr: "Media gallery"}, + {name: "gallery item alt text at 1025", tree: []component.Component{component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: component.UnfurledMediaItem{Url: "https://example.com/1.png"}, Description: longAlt(1025)}}})}, wantErr: "Media gallery"}, + {name: "gallery item alt text with a null byte", tree: []component.Component{component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: component.UnfurledMediaItem{Url: "https://example.com/1.png"}, Description: strPtr("a\x00")}}})}, wantErr: "null characters"}, + + {name: "section with 1 text display", tree: []component.Component{section(thumb("https://example.com/a.png"), texts(1)...)}}, + {name: "section with 3 text displays", tree: []component.Component{section(thumb("https://example.com/a.png"), texts(3)...)}}, + {name: "section with 4 text displays", tree: []component.Component{section(thumb("https://example.com/a.png"), texts(4)...)}, wantErr: "Section"}, + {name: "section with 0 text displays", tree: []component.Component{section(thumb("https://example.com/a.png"))}, wantErr: "Section"}, + {name: "section text with a null byte", tree: []component.Component{section(thumb("https://example.com/a.png"), text("a\x00"))}, wantErr: "null characters"}, + {name: "section with a non text child", tree: []component.Component{section(thumb("https://example.com/a.png"), text("a"), gallery("https://example.com/1.png"))}, wantErr: "Section"}, + {name: "section without accessory", tree: []component.Component{section(component.Component{}, text("a"))}, wantErr: "accessory"}, + {name: "section with text display accessory", tree: []component.Component{section(text("x"), text("a"))}, wantErr: "accessory"}, + {name: "section with link button accessory", tree: []component.Component{section(linkBtn("Go", "https://example.com"), text("a"))}}, + {name: "section with panel button accessory in a multi-panel", tree: []component.Component{section(panelBtn(1), text("a"))}, rules: multiPanelRules}, + {name: "section with panel button accessory outside a multi-panel", tree: []component.Component{section(panelBtn(1), text("a"))}, wantErr: "Panel button"}, + {name: "section with a dropdown accessory", tree: []component.Component{section(dropdownNode(), text("a"))}, rules: multiPanelRules, wantErr: "accessory"}, + + {name: "file", tree: []component.Component{component.BuildFile(component.File{File: component.UnfurledMediaItem{Url: "https://example.com/a.pdf"}})}, wantErr: "File"}, + {name: "container with text", tree: []component.Component{container(text("a"))}}, + {name: "empty container", tree: []component.Component{container()}, wantErr: "Container"}, + {name: "container in container", tree: []component.Component{container(container(text("a")))}, wantErr: "Container"}, + {name: "container holding rows, sections and galleries", tree: []component.Component{container(text("a"), section(thumb("https://example.com/a.png"), text("b")), gallery("https://example.com/1.png"), row(linkBtn("Go", "https://example.com")), component.BuildSeparator(component.Separator{}))}}, + {name: "separator", tree: []component.Component{component.BuildSeparator(component.Separator{})}}, + + {name: "link button", tree: []component.Component{row(linkBtn("Go", "https://example.com"))}}, + {name: "link button with blank label", tree: []component.Component{row(linkBtn(" ", "https://example.com"))}, wantErr: "Link button"}, + {name: "link button label at 80", tree: []component.Component{row(linkBtn(strings.Repeat("a", 80), "https://example.com"))}}, + {name: "link button label at 81", tree: []component.Component{row(linkBtn(strings.Repeat("a", 81), "https://example.com"))}, wantErr: "Link button"}, + {name: "link button label with a null byte", tree: []component.Component{row(linkBtn("G\x00o", "https://example.com"))}, wantErr: "null characters"}, + {name: "link style button with a custom id", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStyleLink, Url: strPtr("https://example.com"), CustomId: "x"}))}, wantErr: "both a link button and a panel button"}, + {name: "link button with a panel custom id", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStyleLink, Url: strPtr("https://example.com"), CustomId: "panel:1"}))}, rules: multiPanelRules, wantErr: "both a link button and a panel button"}, + {name: "link style without a url", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStyleLink}))}, wantErr: "Link button"}, + {name: "url on a non link style", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStylePrimary, Url: strPtr("https://example.com")}))}, wantErr: "Link button"}, + {name: "link button with a sku", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStyleLink, Url: strPtr("https://example.com"), SkuId: new(uint64)}))}, wantErr: "Link button"}, + {name: "arbitrary custom id", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStylePrimary, CustomId: "close"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "no custom id and no url", tree: []component.Component{row(component.BuildButton(component.Button{Label: "Go", Style: component.ButtonStylePrimary}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel placement", tree: []component.Component{row(panelBtn(1), panelBtn(2))}, rules: multiPanelRules}, + {name: "panel placement outside a multi-panel", tree: []component.Component{row(panelBtn(1))}, wantErr: "Panel button"}, + {name: "panel placement of an unknown panel", tree: []component.Component{row(panelBtn(99))}, rules: multiPanelRules, wantErr: "not part of this multi-panel"}, + {name: "panel placed twice", tree: []component.Component{row(panelBtn(1)), row(panelBtn(1))}, rules: multiPanelRules, wantErr: "more than once"}, + {name: "panel marker without an id", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel marker with a non numeric id", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:abc"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel marker with a leading zero", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:01"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel marker with a sign", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:+1"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel marker with id zero", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:0"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "panel marker with an overflowing id", tree: []component.Component{row(component.BuildButton(component.Button{Style: component.ButtonStylePrimary, CustomId: "panel:99999999999999999999"}))}, rules: multiPanelRules, wantErr: "Button"}, + {name: "own panel button in a row", tree: []component.Component{row(linkBtn("Go", "https://example.com"), ownPanelBtn())}, rules: panelMessageRules}, + {name: "own panel button beside a section", tree: []component.Component{container(section(ownPanelBtn(), text("a")))}, rules: panelMessageRules}, + {name: "own panel button placed twice", tree: []component.Component{row(ownPanelBtn()), section(ownPanelBtn(), text("a"))}, rules: panelMessageRules, wantErr: "only be placed once"}, + {name: "own panel button outside a panel message", tree: []component.Component{row(ownPanelBtn())}, wantErr: "Button"}, + {name: "another panel in a panel message", tree: []component.Component{row(panelBtn(1))}, rules: panelMessageRules, wantErr: "Panel button"}, + {name: "bare button outside a row", tree: []component.Component{linkBtn("Go", "https://example.com")}, wantErr: "action row"}, + + {name: "placed dropdown", tree: []component.Component{row(dropdownNode())}, rules: multiPanelRules}, + {name: "placed dropdown without dropdown mode", tree: []component.Component{row(dropdownNode())}, rules: treeRules{validPanelIds: map[int]bool{1: true}}, wantErr: "Dropdown"}, + {name: "placed dropdown outside a multi-panel", tree: []component.Component{row(dropdownNode())}, wantErr: "Dropdown"}, + {name: "placed dropdown twice", tree: []component.Component{row(dropdownNode()), row(dropdownNode())}, rules: multiPanelRules, wantErr: "only be placed once"}, + {name: "dropdown with another custom id", tree: []component.Component{row(component.BuildSelectMenu(component.SelectMenu{CustomId: "other"}))}, rules: multiPanelRules, wantErr: "Dropdown"}, + {name: "dropdown sharing a row", tree: []component.Component{row(dropdownNode(), panelBtn(1))}, rules: multiPanelRules, wantErr: "alone in its row"}, + {name: "bare dropdown outside a row", tree: []component.Component{dropdownNode()}, rules: multiPanelRules, wantErr: "action row"}, + + {name: "empty action row", tree: []component.Component{row()}, wantErr: "Action row"}, + {name: "action row of 5", tree: []component.Component{row(linkBtn("1", "https://example.com"), linkBtn("2", "https://example.com"), linkBtn("3", "https://example.com"), linkBtn("4", "https://example.com"), linkBtn("5", "https://example.com"))}}, + {name: "action row of 6", tree: []component.Component{row(linkBtn("1", "https://example.com"), linkBtn("2", "https://example.com"), linkBtn("3", "https://example.com"), linkBtn("4", "https://example.com"), linkBtn("5", "https://example.com"), linkBtn("6", "https://example.com"))}, wantErr: "Action row"}, + {name: "action row mixing link and panel buttons", tree: []component.Component{row(linkBtn("1", "https://example.com"), panelBtn(1))}, rules: multiPanelRules}, + {name: "action row holding text", tree: []component.Component{row(text("x"))}, wantErr: "Action row"}, + + {name: "text input", tree: []component.Component{component.Component{Type: component.ComponentInputText, ComponentData: component.InputText{}}}, wantErr: "not allowed"}, + {name: "label", tree: []component.Component{component.BuildLabel(component.Label{Label: "x"})}, wantErr: "not allowed"}, + {name: "user select in a row", tree: []component.Component{row(component.Component{Type: component.ComponentUserSelect, ComponentData: component.UserSelect{}})}, rules: multiPanelRules, wantErr: "Action row"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validateComponentTree(tt.tree, tt.rules) + + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.Error(t, err) + + var inputErr *validation.InvalidInputError + require.True(t, errors.As(err, &inputErr), "a rejection must be a client-facing validation error") + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidateComponentTree_Result(t *testing.T) { + tree := []component.Component{ + text("a"), + container(row(panelBtn(1), panelBtn(3)), section(linkBtn("Go", "https://example.com"), text("b"))), + row(dropdownNode()), + } + + result, err := validateComponentTree(tree, multiPanelRules) + require.NoError(t, err) + require.Equal(t, map[int]bool{1: true, 3: true}, result.placed) + require.True(t, result.dropdownUsed) + require.False(t, result.ownPlaced) + require.Equal(t, 10, result.count) + + result, err = validateComponentTree([]component.Component{section(ownPanelBtn(), text("a"))}, panelMessageRules) + require.NoError(t, err) + require.True(t, result.ownPlaced) + require.Empty(t, result.placed) + require.Equal(t, 3, result.count) +} + +func TestParsePanelPlacement(t *testing.T) { + tests := []struct { + customId string + wantId int + wantOk bool + }{ + {"panel:1", 1, true}, + {"panel:123456789", 123456789, true}, + {"panel:1234567890", 0, false}, + {"panel:", 0, false}, + {"panel:0", 0, false}, + {"panel:-1", 0, false}, + {"panel:01", 0, false}, + {"panel: 1", 0, false}, + {"panel:1 ", 0, false}, + {"Panel:1", 0, false}, + {"panel", 0, false}, + {"multipanel", 0, false}, + {"", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.customId, func(t *testing.T) { + id, ok := parsePanelPlacement(tt.customId) + require.Equal(t, tt.wantOk, ok) + require.Equal(t, tt.wantId, id) + }) + } +} + +func TestCheckComponentBudget(t *testing.T) { + tests := []struct { + name string + count int + reserved int + wantErr bool + }{ + {"empty", 0, 0, false}, + {"at the limit", 40, 0, false}, + {"over the limit", 41, 0, true}, + {"at the limit with a reservation", 35, 5, false}, + {"over the limit with a reservation", 36, 5, true}, + {"reservation alone is over", 0, 41, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkComponentBudget(tt.count, tt.reserved) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestNormaliseComponentTree(t *testing.T) { + junkMedia := component.UnfurledMediaItem{Url: "https://example.com/a.png", ProxyUrl: "https://proxy", ContentType: "image/png", Height: 10, Width: 20} + cleanMedia := component.UnfurledMediaItem{Url: "https://example.com/a.png"} + alt := strPtr("alt") + spoiler := true + + tests := []struct { + name string + in component.Component + want component.Component + }{ + { + name: "thumbnail keeps url, description and spoiler only", + in: component.BuildThumbnail(component.Thumbnail{Media: junkMedia, Description: alt, Spoiler: &spoiler}), + want: component.BuildThumbnail(component.Thumbnail{Media: cleanMedia, Description: alt, Spoiler: &spoiler}), + }, + { + name: "gallery items keep url, description and spoiler only", + in: component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: junkMedia, Description: alt, Spoiler: &spoiler}, {Media: junkMedia}}}), + want: component.BuildMediaGallery(component.MediaGallery{Items: []component.MediaGalleryItem{{Media: cleanMedia, Description: alt, Spoiler: &spoiler}, {Media: cleanMedia}}}), + }, + { + name: "dropdown keeps only its custom id", + in: component.BuildSelectMenu(component.SelectMenu{ + CustomId: "multipanel", Placeholder: "x", MinValues: intPtr(3), MaxValues: intPtr(9), Disabled: true, + Options: []component.SelectOption{{Label: "a", Value: "b"}}, + }), + want: component.BuildSelectMenu(component.SelectMenu{CustomId: "multipanel"}), + }, + { + name: "placement button keeps style and custom id only", + in: component.BuildButton(component.Button{Label: "x", CustomId: "panel:3", Style: component.ButtonStyleDanger, Emoji: &emoji.Emoji{Name: "x"}, SkuId: new(uint64), Disabled: true}), + want: component.BuildButton(component.Button{CustomId: "panel:3", Style: component.ButtonStyleDanger}), + }, + { + name: "unsupported placement style becomes primary", + in: component.BuildButton(component.Button{CustomId: "panel:3", Style: component.ButtonStylePremium}), + want: component.BuildButton(component.Button{CustomId: "panel:3", Style: component.ButtonStylePrimary}), + }, + { + name: "placement button with style zero becomes primary", + in: component.BuildButton(component.Button{CustomId: "panel:3"}), + want: component.BuildButton(component.Button{CustomId: "panel:3", Style: component.ButtonStylePrimary}), + }, + { + name: "link button keeps label, url and disabled", + in: component.BuildButton(component.Button{Label: "Go", Url: strPtr("https://example.com"), Style: component.ButtonStyleLink, CustomId: "x", SkuId: new(uint64)}), + want: component.BuildButton(component.Button{Label: "Go", Url: strPtr("https://example.com"), Style: component.ButtonStyleLink}), + }, + { + name: "nested nodes are normalised", + in: container( + section(component.BuildThumbnail(component.Thumbnail{Media: junkMedia}), text("hi")), + row(component.BuildSelectMenu(component.SelectMenu{CustomId: "multipanel", Placeholder: "x"})), + ), + want: container( + section(component.BuildThumbnail(component.Thumbnail{Media: cleanMedia}), text("hi")), + row(component.BuildSelectMenu(component.SelectMenu{CustomId: "multipanel"})), + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normaliseComponentTree([]component.Component{tt.in}) + require.Equal(t, []component.Component{tt.want}, got) + }) + } + + t.Run("nil stays nil", func(t *testing.T) { + require.Nil(t, normaliseComponentTree(nil)) + }) + + t.Run("the input is not mutated", func(t *testing.T) { + in := []component.Component{component.BuildThumbnail(component.Thumbnail{Media: junkMedia})} + normaliseComponentTree(in) + require.Equal(t, junkMedia, in[0].ComponentData.(component.Thumbnail).Media) + }) + + t.Run("junk never reaches the stored json", func(t *testing.T) { + in := []component.Component{ + component.BuildThumbnail(component.Thumbnail{Media: junkMedia}), + row(component.BuildSelectMenu(component.SelectMenu{CustomId: "multipanel", Placeholder: "SECRET"})), + row(component.BuildButton(component.Button{Label: "SECRET", CustomId: "panel:1", Style: component.ButtonStylePrimary})), + } + + stored, err := marshalComponents(normaliseComponentTree(in)) + require.NoError(t, err) + require.NotContains(t, *stored, "SECRET") + require.NotContains(t, *stored, "https://proxy") + require.NotContains(t, *stored, "image/png") + }) +} + +func TestIsPanelButtonStyle(t *testing.T) { + tests := []struct { + style component.ButtonStyle + want bool + }{ + {0, false}, + {component.ButtonStylePrimary, true}, + {component.ButtonStyleSecondary, true}, + {component.ButtonStyleSuccess, true}, + {component.ButtonStyleDanger, true}, + {component.ButtonStyleLink, false}, + {component.ButtonStylePremium, false}, + {7, false}, + {255, false}, + } + + for _, tt := range tests { + t.Run(fmt.Sprint(tt.style), func(t *testing.T) { + require.Equal(t, tt.want, isPanelButtonStyle(tt.style)) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/messagedata_test.go b/backend/app/http/endpoints/api/panel/messagedata_test.go new file mode 100644 index 00000000..45d16d21 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/messagedata_test.go @@ -0,0 +1,736 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/gdl/rest/request" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/botcontext" + "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/log" + "go.uber.org/zap" +) + +func testPanel(panelId int, label, customId string) database.PanelWithCustomization { + return database.PanelWithCustomization{ + Panel: database.Panel{ + PanelId: panelId, + ButtonLabel: label, + CustomId: customId, + ButtonStyle: int(component.ButtonStylePrimary), + }, + } +} + +func marshalToMap(t *testing.T, v any) map[string]any { + t.Helper() + + raw, err := json.Marshal(v) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + return decoded +} + +func TestV2EditData(t *testing.T) { + data := v2EditData([]component.Component{text("hello")}) + decoded := marshalToMap(t, data) + + require.EqualValues(t, message.FlagComponentsV2, decoded["flags"]) + require.Equal(t, []any{}, decoded["embeds"], "a V2 edit must send an explicit empty embeds array") + require.Empty(t, decoded["content"]) + require.Equal(t, map[string]any{"replied_user": false}, decoded["allowed_mentions"], "an empty allowed_mentions object allows no mentions") + require.Len(t, decoded["components"], 1) +} + +func TestPanelMessageData_BuildEditData(t *testing.T) { + t.Run("v2 appends the open ticket row", func(t *testing.T) { + p := &panelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{text("hello")}, + ButtonLabel: "Open a ticket", + CustomId: "open_ticket", + } + + data := p.buildEditData() + + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags) + require.NotNil(t, data.AllowedMentions) + require.NotNil(t, data.Embeds) + require.Empty(t, data.Embeds) + require.Len(t, data.Components, 2) + require.Equal(t, component.ComponentTextDisplay, data.Components[0].Type) + require.Equal(t, component.ComponentActionRow, data.Components[1].Type) + }) + + t.Run("v2 resolves a placed panel button in place", func(t *testing.T) { + tree := []component.Component{container(section(ownPanelBtn(), text("hello")))} + p := &panelMessageData{ + UsesComponentsV2: true, + Components: tree, + ButtonLabel: "Open a ticket", + CustomId: "open_ticket", + ButtonStyle: component.ButtonStyleSuccess, + ButtonDisabled: true, + } + + data := p.buildEditData() + + require.Len(t, data.Components, 1) + sent := data.Components[0].ComponentData.(component.Container).Components[0].ComponentData.(component.Section) + require.Equal(t, p.button(), sent.Accessory.ComponentData) + + stored := tree[0].ComponentData.(component.Container).Components[0].ComponentData.(component.Section) + require.Equal(t, ownPanelCustomId, stored.Accessory.ComponentData.(component.Button).CustomId) + }) + + t.Run("classic is unchanged", func(t *testing.T) { + p := &panelMessageData{Title: "Support", ButtonLabel: "Open a ticket", CustomId: "open_ticket"} + + data := p.buildEditData() + decoded := marshalToMap(t, data) + + require.Zero(t, data.Flags) + require.Nil(t, data.AllowedMentions) + require.NotContains(t, decoded, "allowed_mentions") + require.Len(t, data.Embeds, 1) + require.Len(t, data.Components, 1) + }) +} + +func TestMultiPanelMessageData_BuildEditData(t *testing.T) { + t.Run("v2", func(t *testing.T) { + d := &multiPanelMessageData{UsesComponentsV2: true} + data := d.buildEditData([]component.Component{text("hello")}) + + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags) + require.NotNil(t, data.AllowedMentions) + require.Empty(t, data.Embeds) + require.Len(t, data.Components, 1) + }) + + t.Run("classic is unchanged", func(t *testing.T) { + branding := fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy) + + tests := []struct { + name string + footer footerPolicy + wantFooter *string + }{ + {"branding", footerPolicy{ShowBranding: true}, &branding}, + {"no custom footer", footerPolicy{}, nil}, + {"custom footer", footerPolicy{AllowCustom: true}, strPtr("custom")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &multiPanelMessageData{Footer: tt.footer, Embed: embed.NewEmbed().SetFooter("custom", "")} + data := d.buildEditData([]component.Component{row(linkBtn("Go", "https://example.com"))}) + + require.Zero(t, data.Flags) + require.Nil(t, data.AllowedMentions) + require.Len(t, data.Embeds, 1) + require.Len(t, data.Components, 1) + + if tt.wantFooter == nil { + require.Nil(t, data.Embeds[0].Footer) + return + } + + require.NotNil(t, data.Embeds[0].Footer) + require.Equal(t, *tt.wantFooter, data.Embeds[0].Footer.Text) + }) + } + }) +} + +type sentRequest struct { + method, path string + body []byte +} + +type fakeTransport func(*http.Request) (*http.Response, error) + +func (f fakeTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func useTransport(t *testing.T, transport fakeTransport) { + prev := request.Client.Transport + request.Client.Transport = transport + t.Cleanup(func() { request.Client.Transport = prev }) +} + +func captureSends(t *testing.T) *[]sentRequest { + var sent []sentRequest + useTransport(t, func(r *http.Request) (*http.Response, error) { + var body []byte + if r.Body != nil { + body, _ = io.ReadAll(r.Body) + } + + sent = append(sent, sentRequest{method: r.Method, path: r.URL.Path, body: body}) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"42"}`)), Request: r}, nil + }) + + return &sent +} + +func createdMessage(t *testing.T, sent []sentRequest) (rest.CreateMessageData, string) { + t.Helper() + + require.Len(t, sent, 1) + require.Equal(t, http.MethodPost, sent[0].method) + require.Equal(t, "/api/v10/channels/7/messages", sent[0].path) + + var data rest.CreateMessageData + require.NoError(t, json.Unmarshal(sent[0].body, &data)) + return data, string(sent[0].body) +} + +func TestPanelMessageData_Send(t *testing.T) { + t.Run("v2", func(t *testing.T) { + sent := captureSends(t) + + p := &panelMessageData{ChannelId: 7, UsesComponentsV2: true, Components: []component.Component{text("hello")}, ButtonLabel: "Open a ticket", CustomId: "open_ticket", ButtonStyle: component.ButtonStylePrimary} + id, err := p.send(&botcontext.BotContext{}) + require.NoError(t, err) + require.EqualValues(t, 42, id) + + data, _ := createdMessage(t, *sent) + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags) + require.Empty(t, data.Embeds) + require.Len(t, data.Components, 2) + require.Equal(t, component.ComponentTextDisplay, data.Components[0].Type) + + buttons := rowButtons(t, data.Components[1]) + require.Len(t, buttons, 1) + require.Equal(t, "open_ticket", buttons[0].CustomId) + }) + + t.Run("v2 with the panel button placed", func(t *testing.T) { + sent := captureSends(t) + + p := &panelMessageData{ChannelId: 7, UsesComponentsV2: true, Components: []component.Component{text("hello"), row(linkBtn("Docs", "https://example.com"), ownPanelBtn())}, ButtonLabel: "Open a ticket", CustomId: "open_ticket", ButtonStyle: component.ButtonStylePrimary} + _, err := p.send(&botcontext.BotContext{}) + require.NoError(t, err) + + data, body := createdMessage(t, *sent) + require.Len(t, data.Components, 2) + require.NotContains(t, body, ownPanelCustomId) + + buttons := rowButtons(t, data.Components[1]) + require.Len(t, buttons, 2) + require.Equal(t, "Docs", buttons[0].Label) + require.Equal(t, "open_ticket", buttons[1].CustomId) + require.Equal(t, "Open a ticket", buttons[1].Label) + }) + + t.Run("classic", func(t *testing.T) { + sent := captureSends(t) + + p := &panelMessageData{ChannelId: 7, Title: "Support", ButtonLabel: "Open a ticket", CustomId: "open_ticket", ButtonStyle: component.ButtonStylePrimary} + _, err := p.send(&botcontext.BotContext{}) + require.NoError(t, err) + + data, _ := createdMessage(t, *sent) + require.Zero(t, data.Flags) + require.Len(t, data.Embeds, 1) + require.Len(t, data.Components, 1) + }) +} + +func TestMultiPanelMessageData_Send(t *testing.T) { + panels := []database.PanelWithCustomization{testPanel(1, "One", "c1"), testPanel(2, "Two", "c2"), testPanel(3, "Three", "c3")} + + t.Run("v2", func(t *testing.T) { + sent := captureSends(t) + + d := &multiPanelMessageData{ChannelId: 7, UsesComponentsV2: true, Components: []component.Component{text("intro"), row(panelBtn(2))}} + id, err := d.send(&botcontext.BotContext{}, panels) + require.NoError(t, err) + require.EqualValues(t, 42, id) + + data, raw := createdMessage(t, *sent) + require.NotContains(t, raw, "panel:") + require.Equal(t, message.SumFlags(message.FlagComponentsV2), data.Flags) + require.Empty(t, data.Embeds) + require.Len(t, data.Components, 3) + require.Equal(t, component.ComponentTextDisplay, data.Components[0].Type) + + placed := rowButtons(t, data.Components[1]) + require.Len(t, placed, 1) + require.Equal(t, "c2", placed[0].CustomId) + + appended := rowButtons(t, data.Components[2]) + require.Len(t, appended, 2) + require.Equal(t, "c1", appended[0].CustomId) + require.Equal(t, "c3", appended[1].CustomId) + }) + + t.Run("classic", func(t *testing.T) { + sent := captureSends(t) + + d := &multiPanelMessageData{ChannelId: 7, Embed: embed.NewEmbed().SetTitle("Pick a topic")} + _, err := d.send(&botcontext.BotContext{}, panels) + require.NoError(t, err) + + data, _ := createdMessage(t, *sent) + require.Zero(t, data.Flags) + require.Len(t, data.Embeds, 1) + require.Len(t, data.Components, 1) + require.Len(t, rowButtons(t, data.Components[0]), 3) + }) + + t.Run("v2 with only appended panels", func(t *testing.T) { + sent := captureSends(t) + + d := &multiPanelMessageData{ChannelId: 7, UsesComponentsV2: true} + _, err := d.send(&botcontext.BotContext{}, panels) + require.NoError(t, err) + + data, _ := createdMessage(t, *sent) + require.Len(t, data.Components, 1) + require.Len(t, rowButtons(t, data.Components[0]), 3) + }) + + t.Run("v2 with nothing left to show", func(t *testing.T) { + useTransport(t, func(r *http.Request) (*http.Response, error) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + return nil, errors.New("unexpected request") + }) + + d := &multiPanelMessageData{ChannelId: 7, UsesComponentsV2: true, Components: []component.Component{row(panelBtn(1), panelBtn(2))}} + _, err := d.send(&botcontext.BotContext{}, nil) + require.ErrorIs(t, err, errEmptyMultiPanel) + }) +} + +func TestMultiPanelMessageData_RendersEmpty(t *testing.T) { + tests := []struct { + name string + data multiPanelMessageData + panels []database.PanelWithCustomization + want bool + }{ + {name: "row of placements", data: multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{row(panelBtn(1), panelBtn(2))}}, want: true}, + {name: "sections with panel accessories", data: multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{container(section(panelBtn(1), text("a")))}}, want: true}, + {name: "empty tree", data: multiPanelMessageData{UsesComponentsV2: true}, want: true}, + {name: "placed dropdown", data: multiPanelMessageData{UsesComponentsV2: true, SelectMenu: true, Components: []component.Component{row(dropdownNode())}}, want: true}, + {name: "text and placements", data: multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{text("a"), row(panelBtn(1))}}}, + {name: "a placed panel left", data: multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{row(panelBtn(1))}}, panels: []database.PanelWithCustomization{testPanel(1, "One", "c1")}}, + {name: "panels left to append", data: multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{row(panelBtn(2))}}, panels: []database.PanelWithCustomization{testPanel(1, "One", "c1")}}, + {name: "classic", data: multiPanelMessageData{Embed: embed.NewEmbed()}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.data.rendersEmpty(tt.panels)) + }) + } +} + +func rowButtons(t *testing.T, c component.Component) []component.Button { + t.Helper() + + r, ok := c.ComponentData.(component.ActionRow) + require.True(t, ok) + + buttons := make([]component.Button, len(r.Components)) + for i, child := range r.Components { + buttons[i], ok = child.ComponentData.(component.Button) + require.True(t, ok) + } + return buttons +} + +func TestBuildComponents_Classic(t *testing.T) { + panels := []database.PanelWithCustomization{testPanel(1, "One", "c1"), testPanel(2, "Two", "c2")} + + t.Run("ignores any tree", func(t *testing.T) { + d := multiPanelMessageData{Components: []component.Component{row(panelBtn(1))}} + + tree, system := d.buildComponents(panels) + require.Nil(t, tree) + require.Len(t, system, 1) + require.Len(t, rowButtons(t, system[0]), 2) + }) + + t.Run("splits button rows at 5", func(t *testing.T) { + many := make([]database.PanelWithCustomization, 11) + for i := range many { + many[i] = testPanel(i+1, "P", "c") + } + + _, system := (&multiPanelMessageData{}).buildComponents(many) + require.Len(t, system, 3) + require.Len(t, rowButtons(t, system[0]), 5) + require.Len(t, rowButtons(t, system[1]), 5) + require.Len(t, rowButtons(t, system[2]), 1) + }) + + t.Run("exactly 5 panels is one row", func(t *testing.T) { + five := []database.PanelWithCustomization{testPanel(1, "P", "a"), testPanel(2, "P", "b"), testPanel(3, "P", "c"), testPanel(4, "P", "d"), testPanel(5, "P", "e")} + + _, system := (&multiPanelMessageData{}).buildComponents(five) + require.Len(t, system, 1) + }) + + t.Run("dropdown uses the configured placeholder", func(t *testing.T) { + d := multiPanelMessageData{SelectMenu: true, SelectMenuPlaceholder: strPtr("Pick one")} + + _, system := d.buildComponents(panels) + require.Len(t, system, 1) + + sm := system[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu) + require.Equal(t, "multipanel", sm.CustomId) + require.Equal(t, "Pick one", sm.Placeholder) + require.Len(t, sm.Options, 2) + }) + + t.Run("dropdown falls back to the default placeholder", func(t *testing.T) { + d := multiPanelMessageData{SelectMenu: true} + + _, system := d.buildComponents(panels) + sm := system[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu) + require.Equal(t, "Select a topic...", sm.Placeholder) + }) + + t.Run("no panels appends nothing", func(t *testing.T) { + tree, system := (&multiPanelMessageData{}).buildComponents(nil) + require.Nil(t, tree) + require.Nil(t, system) + }) +} + +func TestBuildPanelSelectOptions(t *testing.T) { + blank := testPanel(1, "One", "c1") + blank.Description = strPtr("") + described := testPanel(2, "Two", "c2") + described.Description = strPtr("About two") + + options := buildPanelSelectOptions([]database.PanelWithCustomization{blank, described, testPanel(3, "Three", "c3")}) + require.Len(t, options, 3) + require.Nil(t, options[0].Description) + require.Equal(t, strPtr("About two"), options[1].Description) + require.Nil(t, options[2].Description) +} + +func TestBuildComponents_V2(t *testing.T) { + panels := []database.PanelWithCustomization{testPanel(1, "One", "c1"), testPanel(2, "Two", "c2"), testPanel(3, "Three", "c3")} + + t.Run("placed panels resolved, rest appended", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{text("intro"), row(panelBtn(2))}} + + tree, system := d.buildComponents(panels) + require.Len(t, tree, 2) + + placed := rowButtons(t, tree[1]) + require.Len(t, placed, 1) + require.Equal(t, "c2", placed[0].CustomId) + require.Equal(t, "Two", placed[0].Label) + + require.Len(t, system, 1) + appended := rowButtons(t, system[0]) + require.Len(t, appended, 2) + require.Equal(t, "c1", appended[0].CustomId) + require.Equal(t, "c3", appended[1].CustomId) + }) + + t.Run("panel buttons in sections are resolved", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{ + section(panelBtn(2), text("side")), + container(section(panelBtn(3), text("nested"))), + }} + + tree, system := d.buildComponents(panels) + require.Len(t, tree, 2) + + accessory := tree[0].ComponentData.(component.Section).Accessory.ComponentData.(component.Button) + require.Equal(t, "c2", accessory.CustomId) + require.Equal(t, "Two", accessory.Label) + + nested := tree[1].ComponentData.(component.Container).Components[0].ComponentData.(component.Section).Accessory.ComponentData.(component.Button) + require.Equal(t, "c3", nested.CustomId) + require.Equal(t, "Three", nested.Label) + + require.Len(t, system, 1) + appended := rowButtons(t, system[0]) + require.Len(t, appended, 1) + require.Equal(t, "c1", appended[0].CustomId) + + raw, err := json.Marshal(append(tree, system...)) + require.NoError(t, err) + require.NotContains(t, string(raw), "panel:") + }) + + t.Run("everything placed appends nothing", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{row(panelBtn(1), panelBtn(2), panelBtn(3))}} + + _, system := d.buildComponents(panels) + require.Nil(t, system) + }) + + t.Run("placed dropdown lists only unplaced panels", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + SelectMenu: true, + Components: []component.Component{row(dropdownNode()), container(row(panelBtn(1)))}, + } + + tree, system := d.buildComponents(panels) + require.Nil(t, system, "a placed dropdown replaces the appended one") + require.Len(t, tree, 2) + + sm := tree[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu) + require.Equal(t, "multipanel", sm.CustomId) + require.Len(t, sm.Options, 2) + require.Equal(t, "c2", sm.Options[0].Value) + require.Equal(t, "c3", sm.Options[1].Value) + }) + + t.Run("dropdowns share the configured placeholder", func(t *testing.T) { + placeholder := strPtr("Choose") + + placed := multiPanelMessageData{UsesComponentsV2: true, SelectMenu: true, SelectMenuPlaceholder: placeholder, Components: []component.Component{row(dropdownNode())}} + tree, _ := placed.buildComponents(panels) + require.Equal(t, "Choose", tree[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu).Placeholder) + + appended := multiPanelMessageData{UsesComponentsV2: true, SelectMenu: true, SelectMenuPlaceholder: placeholder} + _, system := appended.buildComponents(panels) + require.Equal(t, "Choose", system[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu).Placeholder) + }) + + t.Run("dropdown mode without a placed dropdown appends one", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, SelectMenu: true, Components: []component.Component{text("hi"), row(panelBtn(1))}} + + _, system := d.buildComponents(panels) + require.Len(t, system, 1) + + sm := system[0].ComponentData.(component.ActionRow).Components[0].ComponentData.(component.SelectMenu) + require.Len(t, sm.Options, 2) + }) + + t.Run("empty dropdown dropped with its row", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + SelectMenu: true, + Components: []component.Component{text("hi"), row(dropdownNode()), row(panelBtn(1), panelBtn(2), panelBtn(3))}, + } + + tree, system := d.buildComponents(panels) + require.Nil(t, system) + require.Len(t, tree, 2) + require.Equal(t, component.ComponentTextDisplay, tree[0].Type) + require.Equal(t, component.ComponentActionRow, tree[1].Type) + }) + + t.Run("a placed dropdown is dropped in button mode", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{text("hi"), row(dropdownNode())}} + + tree, system := d.buildComponents(panels) + require.Len(t, tree, 1) + require.Len(t, system, 1) + require.Len(t, rowButtons(t, system[0]), 3) + }) + + t.Run("a second dropdown is dropped", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, SelectMenu: true, Components: []component.Component{row(dropdownNode()), row(dropdownNode())}} + + tree, _ := d.buildComponents(panels) + require.Len(t, tree, 1) + }) + + t.Run("stale placement dropped with what it empties", func(t *testing.T) { + d := multiPanelMessageData{ + UsesComponentsV2: true, + Components: []component.Component{ + text("hi"), + row(panelBtn(99)), + container(row(panelBtn(98))), + section(panelBtn(97), text("side")), + row(panelBtn(98), panelBtn(1)), + }, + } + + tree, system := d.buildComponents(panels) + require.Len(t, tree, 2) + require.Equal(t, component.ComponentTextDisplay, tree[0].Type) + require.Len(t, rowButtons(t, tree[1]), 1) + require.Len(t, rowButtons(t, system[0]), 2) + }) + + t.Run("a panel placed twice is only resolved once", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{row(panelBtn(1)), row(panelBtn(1))}} + + tree, _ := d.buildComponents(panels) + require.Len(t, tree, 1) + }) + + t.Run("link button and link accessory kept", func(t *testing.T) { + d := multiPanelMessageData{UsesComponentsV2: true, Components: []component.Component{ + row(linkBtn("Docs", "https://example.com")), + section(linkBtn("More", "https://example.com/more"), text("side")), + section(thumb("https://example.com/a.png"), text("pic")), + gallery("https://example.com/1.png"), + component.BuildSeparator(component.Separator{}), + }} + + tree, _ := d.buildComponents(panels) + require.Len(t, tree, 5) + }) +} + +func TestBuildComponents_NoMarkerReachesDiscord(t *testing.T) { + panels := []database.PanelWithCustomization{testPanel(1, "One", "c1"), testPanel(2, "Two", "c2")} + + hostile := []component.Component{ + row(panelBtn(1), panelBtn(99)), + row(component.BuildButton(component.Button{Label: "x", Style: component.ButtonStylePrimary, CustomId: "panel:abc"})), + row(component.BuildButton(component.Button{Label: "x", Style: component.ButtonStylePrimary, CustomId: "panel:01"})), + row(component.BuildButton(component.Button{Label: "x", Style: component.ButtonStylePrimary, CustomId: "close"})), + row(component.BuildButton(component.Button{Label: "x", Style: component.ButtonStyleLink, CustomId: "panel:2", Url: strPtr("https://example.com")})), + container(row(panelBtn(98)), section(panelBtn(97), text("a")), row(panelBtn(2))), + section(component.BuildButton(component.Button{Label: "x", Style: component.ButtonStylePrimary, CustomId: "panel:5"}), text("a")), + row(component.BuildSelectMenu(component.SelectMenu{CustomId: "something-else"})), + component.BuildLabel(component.Label{Label: "x"}), + component.Component{Type: component.ComponentInputText, ComponentData: component.InputText{}}, + component.BuildFile(component.File{}), + } + + for _, dropdownMode := range []bool{false, true} { + d := multiPanelMessageData{UsesComponentsV2: true, SelectMenu: dropdownMode, Components: hostile} + + tree, system := d.buildComponents(panels) + + raw, err := json.Marshal(append(tree, system...)) + require.NoError(t, err) + + require.False(t, strings.Contains(string(raw), "panel:"), "marker leaked into the outgoing payload: %s", raw) + require.NotContains(t, string(raw), `"custom_id":"close"`) + require.NotContains(t, string(raw), "something-else") + require.NotContains(t, string(raw), `"type":19`) + require.NotContains(t, string(raw), `"type":13`) + } +} + +func TestMapComponentTree_DropsParentsThatCannotRender(t *testing.T) { + dropAll := func(component.Component) (component.Component, bool) { return component.Component{}, false } + keepAll := func(c component.Component) (component.Component, bool) { return c, true } + + tree := []component.Component{ + container(row(linkBtn("Go", "https://example.com"))), + section(thumb("https://example.com/a.png"), text("a")), + } + + require.Empty(t, mapComponentTree(tree, dropAll)) + require.Len(t, mapComponentTree(tree, keepAll), 2) + require.Empty(t, mapComponentTree(nil, keepAll)) +} + +func TestCorruptStoredTreeStaysComponentsV2(t *testing.T) { + log.Logger = zap.NewNop() + + corrupt := strPtr("{not json") + valid := strPtr(`[{"type":10,"content":"hi"}]`) + + t.Run("panel", func(t *testing.T) { + tests := []struct { + name string + components *string + wantComponents int + }{ + {"corrupt tree renders empty", corrupt, 0}, + {"wrong json shape renders empty", strPtr(`{"a":1}`), 0}, + {"null column is an empty tree", nil, 0}, + {"valid tree is kept", valid, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := panelIntoMessageData(database.Panel{MessageUsesComponentsV2: true, MessageComponents: tt.components, CustomId: "open"}, false) + require.True(t, data.UsesComponentsV2) + require.Len(t, data.Components, tt.wantComponents) + + edit := data.buildEditData() + require.NotZero(t, edit.Flags) + require.Empty(t, edit.Embeds) + require.Len(t, edit.Components, tt.wantComponents+1, "the open ticket row is still rendered") + }) + } + }) + + t.Run("multi-panel", func(t *testing.T) { + tests := []struct { + name string + components *string + wantComponents int + }{ + {"corrupt tree renders empty", corrupt, 0}, + {"valid tree is kept", valid, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := multiPanelIntoMessageData(database.MultiPanel{UsesComponentsV2: true, Components: tt.components}, footerPolicy{}) + require.True(t, data.UsesComponentsV2) + require.Nil(t, data.Embed) + require.Len(t, data.Components, tt.wantComponents) + + tree, system := data.buildComponents([]database.PanelWithCustomization{testPanel(1, "One", "c1")}) + require.Len(t, tree, tt.wantComponents) + require.Len(t, system, 1, "the system rows still render") + }) + } + }) + + t.Run("a classic panel is unaffected", func(t *testing.T) { + data := panelIntoMessageData(database.Panel{MessageComponents: corrupt}, false) + require.False(t, data.UsesComponentsV2) + }) +} + +func TestComponentsToStore(t *testing.T) { + stored := `[{"type":10,"content":"stored"}]` + + tests := []struct { + name string + usesV2 bool + tree []component.Component + stored *string + want *string + }{ + {name: "classic keeps the stored tree", tree: texts(1), stored: &stored, want: &stored}, + {name: "classic ignores a submitted tree", tree: []component.Component{text("submitted")}, stored: &stored, want: &stored}, + {name: "classic with nothing stored stays null", tree: texts(1), stored: nil, want: nil}, + {name: "classic keeps the stored tree", tree: nil, stored: &stored, want: &stored}, + {name: "v2 replaces the stored tree", usesV2: true, tree: []component.Component{text("submitted")}, stored: &stored, want: strPtr(`[{"type":10,"content":"submitted"}]`)}, + {name: "v2 with no tree stores an empty array", usesV2: true, tree: nil, stored: &stored, want: strPtr("[]")}, + {name: "v2 with nothing stored", usesV2: true, tree: []component.Component{text("submitted")}, want: strPtr(`[{"type":10,"content":"submitted"}]`)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := componentsToStore(tt.usesV2, tt.tree, tt.stored) + require.NoError(t, err) + + if tt.want == nil { + require.Nil(t, got) + return + } + + require.NotNil(t, got) + require.JSONEq(t, *tt.want, *got) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/multipanelcreate.go b/backend/app/http/endpoints/api/panel/multipanelcreate.go index b2d6599b..12bca8c1 100644 --- a/backend/app/http/endpoints/api/panel/multipanelcreate.go +++ b/backend/app/http/endpoints/api/panel/multipanelcreate.go @@ -9,6 +9,7 @@ import ( "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" @@ -39,20 +40,130 @@ func getEffectiveLabelForValidation(buttonLabel string, customLabel *string) str } type multiPanelCreateData struct { - ChannelId uint64 `json:"channel_id,string"` - SelectMenu bool `json:"select_menu"` - SelectMenuPlaceholder *string `json:"select_menu_placeholder,omitempty" validate:"omitempty,max=150"` - Panels []panelConfiguration `json:"panels" validate:"dive"` - Embed *types.CustomEmbed `json:"embed" validate:"omitempty"` + Name string `json:"name"` + ChannelId uint64 `json:"channel_id,string"` + SelectMenu bool `json:"select_menu"` + SelectMenuPlaceholder *string `json:"select_menu_placeholder,omitempty" validate:"omitempty,max=150"` + Panels []panelConfiguration `json:"panels" validate:"dive"` + Embed *types.CustomEmbed `json:"embed" validate:"omitempty"` + UsesComponentsV2 bool `json:"uses_components_v2"` + Components []component.Component `json:"components"` } func (d *multiPanelCreateData) IntoMessageData(footer footerPolicy) multiPanelMessageData { - return multiPanelMessageData{ + data := multiPanelMessageData{ Footer: footer, ChannelId: d.ChannelId, SelectMenu: d.SelectMenu, SelectMenuPlaceholder: d.SelectMenuPlaceholder, - Embed: d.Embed.IntoDiscordEmbed(), + UsesComponentsV2: d.UsesComponentsV2, + Components: d.Components, + } + + if !d.UsesComponentsV2 { + data.Embed = d.Embed.IntoDiscordEmbed() + } + + return data +} + +func validateMultiPanelComponents(data multiPanelCreateData) error { + if !data.UsesComponentsV2 { + return nil + } + + validPanelIds := make(map[int]bool, len(data.Panels)) + for _, p := range data.Panels { + validPanelIds[p.PanelId] = true + } + + result, err := validateComponentTree(data.Components, treeRules{validPanelIds: validPanelIds, dropdownMode: data.SelectMenu}) + if err != nil { + return err + } + + unplaced := len(validPanelIds) - len(result.placed) + if result.dropdownUsed && unplaced == 0 { + return validation.NewInvalidInputError("Dropdown: every panel is already placed as a button") + } + + return checkComponentBudget(result.count, multiPanelReserved(data.SelectMenu, result.dropdownUsed, unplaced)) +} + +func multiPanelReserved(dropdownMode, dropdownPlaced bool, unplaced int) int { + switch { + case unplaced == 0 || dropdownPlaced: + return 0 + case dropdownMode: + return dropdownReserved + default: + rows := (unplaced + maxActionRowChildren - 1) / maxActionRowChildren + return rows + unplaced + } +} + +func (d *multiPanelCreateData) stripUnknownPlacements() { + known := make(map[int]bool, len(d.Panels)) + for _, p := range d.Panels { + known[p.PanelId] = true + } + + d.Components = mapComponentTree(d.Components, func(c component.Component) (component.Component, bool) { + if button, ok := c.ComponentData.(component.Button); ok { + if panelId, isPlacement := parsePanelPlacement(button.CustomId); isPlacement && !known[panelId] { + return component.Component{}, false + } + } + + return c, true + }) +} + +func embedIntoDatabase(e *types.CustomEmbed) *database.CustomEmbedWithFields { + if e == nil { + return nil + } + + dbEmbed, dbEmbedFields := e.IntoDatabaseStruct() + return &database.CustomEmbedWithFields{CustomEmbed: dbEmbed, Fields: dbEmbedFields} +} + +func findPanelConfig(configs []panelConfiguration, panelId int) *panelConfiguration { + for i := range configs { + if configs[i].PanelId == panelId { + return &configs[i] + } + } + + return nil +} + +func validateDropdownLabels(panels []database.Panel, configs []panelConfiguration) error { + for _, panel := range panels { + label := panel.ButtonLabel + if config := findPanelConfig(configs, panel.PanelId); config != nil { + label = getEffectiveLabelForValidation(panel.ButtonLabel, config.CustomLabel) + } + + if label == "" { + displayName := panel.Title + if panel.Name != nil && *panel.Name != "" { + displayName = *panel.Name + } + + return validation.NewInvalidInputErrorf("Panel '%s' must have a label when using dropdown mode. Please add a custom label or ensure the panel has a button label.", displayName) + } + } + + return nil +} + +func respondValidationError(c *gin.Context, err error, internalMessage string) { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + c.JSON(400, utils.ErrorStr("%s", validationError.Error())) + } else { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, internalMessage)) } } @@ -71,6 +182,24 @@ func MultiPanelCreate(c *gin.Context) { return } + botContext, err := botcontext.ContextForGuild(guildId) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) + return + } + + if _, ok := componentsV2Gate(c, userId, guildId, botContext, data.entersComponentsV2(database.MultiPanel{}), data.UsesComponentsV2, nil, nil, "Failed to verify premium status"); !ok { + return + } + + if data.UsesComponentsV2 { + data.Embed = classicEmbedForV2(data.Embed) + data.stripUnknownPlacements() + } else if data.Embed == nil { + c.JSON(400, utils.ErrorStr("An embed is required for classic messages.")) + return + } + if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if ok := errors.As(err, &validationErrors); !ok { @@ -83,43 +212,28 @@ func MultiPanelCreate(c *gin.Context) { return } + name, err := normaliseMultiPanelName(data.Name) + if err != nil { + respondValidationError(c, err, "Failed to create multi-panel") + return + } + // validate body & get sub-panels panels, err := data.doValidations(guildId) if err != nil { - var validationError *validation.InvalidInputError - if errors.As(err, &validationError) { - c.JSON(400, utils.ErrorStr("%s", validationError.Error())) - } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) - } + respondValidationError(c, err, "Failed to create multi-panel") return } - // Validate labels for dropdown mode if data.SelectMenu { - for _, panel := range panels { - panelConfig := data.Panels[0] - for i, cfg := range data.Panels { - if panels[i].PanelId == cfg.PanelId { - panelConfig = cfg - break - } - } - - effectiveLabel := getEffectiveLabelForValidation(panel.ButtonLabel, panelConfig.CustomLabel) - - if effectiveLabel == "" { - formatted := fmt.Sprintf("Panel '%s' must have a label when using dropdown mode. Please add a custom label or ensure the panel has a button label.", panel.Title) - c.JSON(400, utils.ErrorStr("%s", formatted)) - return - } + if err := validateDropdownLabels(panels, data.Panels); err != nil { + respondValidationError(c, err, "Failed to create multi-panel") + return } } - // get bot context - botContext, err := botcontext.ContextForGuild(guildId) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) + if err := validateMultiPanelComponents(data); err != nil { + respondValidationError(c, err, "Failed to create multi-panel") return } @@ -141,6 +255,10 @@ func MultiPanelCreate(c *gin.Context) { } } + if data.UsesComponentsV2 { + data.Components = normaliseComponentTree(data.Components) + } + messageData := data.IntoMessageData(footer) messageId, err := messageData.send(botContext, panelsWithCustom) if err != nil { @@ -158,17 +276,32 @@ func MultiPanelCreate(c *gin.Context) { return } - dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + stored := false + defer func() { + if !stored { + discardMessage(botContext, data.ChannelId, messageId) + } + }() + + var componentsJSON *string + if data.UsesComponentsV2 { + componentsJSON, err = marshalComponents(data.Components) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) + return + } + } + multiPanel := database.MultiPanel{ MessageId: messageId, ChannelId: data.ChannelId, GuildId: guildId, SelectMenu: data.SelectMenu, SelectMenuPlaceholder: data.SelectMenuPlaceholder, - Embed: &database.CustomEmbedWithFields{ - CustomEmbed: dbEmbed, - Fields: dbEmbedFields, - }, + Embed: embedIntoDatabase(data.Embed), + UsesComponentsV2: data.UsesComponentsV2, + Components: componentsJSON, + Name: name, } multiPanel.Id, err = dbclient.Client.MultiPanels.Create(c, multiPanel) @@ -176,6 +309,7 @@ func MultiPanelCreate(c *gin.Context) { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create multi-panel")) return } + stored = true group, _ := errgroup.WithContext(context.Background()) for i, panel := range panels { @@ -220,8 +354,10 @@ func MultiPanelCreate(c *gin.Context) { } func (d *multiPanelCreateData) doValidations(guildId uint64) (panels []database.Panel, err error) { - if err := validateEmbed(d.Embed); err != nil { - return nil, err + if !d.UsesComponentsV2 { + if err := validateEmbed(d.Embed); err != nil { + return nil, err + } } group, _ := errgroup.WithContext(context.Background()) @@ -277,12 +413,18 @@ func (d *multiPanelCreateData) validatePanels(guildId uint64) (panels []database return } - existingPanels, err := dbclient.Client.Panel.GetByGuild(context.Background(), guildId) + existingPanels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(context.Background(), guildId) if err != nil { return nil, err } + seen := make(map[int]struct{}, len(d.Panels)) for _, panelConfig := range d.Panels { + if _, duplicate := seen[panelConfig.PanelId]; duplicate { + return nil, validation.NewInvalidInputError("each panel can only be added to a multi-panel once") + } + seen[panelConfig.PanelId] = struct{}{} + var valid bool // find panel struct for _, panel := range existingPanels { diff --git a/backend/app/http/endpoints/api/panel/multipaneldelete.go b/backend/app/http/endpoints/api/panel/multipaneldelete.go index 8a907e1f..568f5b10 100644 --- a/backend/app/http/endpoints/api/panel/multipaneldelete.go +++ b/backend/app/http/endpoints/api/panel/multipaneldelete.go @@ -40,6 +40,11 @@ func MultiPanelDelete(c *gin.Context) { } panel, ok, err := dbclient.Client.MultiPanels.Get(c, multiPanelId) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to delete multi-panel")) + return + } + if !ok { c.JSON(404, utils.ErrorStr("No panel with matching ID found")) return diff --git a/backend/app/http/endpoints/api/panel/multipanelget.go b/backend/app/http/endpoints/api/panel/multipanelget.go index f791ce34..99fd9209 100644 --- a/backend/app/http/endpoints/api/panel/multipanelget.go +++ b/backend/app/http/endpoints/api/panel/multipanelget.go @@ -22,6 +22,7 @@ func MultiPanelGet(ctx *gin.Context) { type multiPanelResponse struct { Id int `json:"id"` + Name *string `json:"name"` MessageId uint64 `json:"message_id,string"` ChannelId uint64 `json:"channel_id,string"` GuildId uint64 `json:"guild_id,string"` @@ -29,6 +30,8 @@ func MultiPanelGet(ctx *gin.Context) { SelectMenuPlaceholder *string `json:"select_menu_placeholder"` Embed *types.CustomEmbed `json:"embed"` Panels []panelConfiguration `json:"panels"` + UsesComponentsV2 bool `json:"uses_components_v2"` + Components *string `json:"components"` } guildId := ctx.Keys["guildid"].(uint64) @@ -82,6 +85,7 @@ func MultiPanelGet(ctx *gin.Context) { ctx.JSON(200, gin.H{ "data": multiPanelResponse{ Id: multiPanel.Id, + Name: multiPanel.Name, MessageId: multiPanel.MessageId, ChannelId: multiPanel.ChannelId, GuildId: multiPanel.GuildId, @@ -89,6 +93,8 @@ func MultiPanelGet(ctx *gin.Context) { SelectMenuPlaceholder: multiPanel.SelectMenuPlaceholder, Embed: transformedEmbed, Panels: configs, + UsesComponentsV2: multiPanel.UsesComponentsV2, + Components: multiPanel.Components, }, }) } diff --git a/backend/app/http/endpoints/api/panel/multipanellist.go b/backend/app/http/endpoints/api/panel/multipanellist.go index a5572570..b82e4e94 100644 --- a/backend/app/http/endpoints/api/panel/multipanellist.go +++ b/backend/app/http/endpoints/api/panel/multipanellist.go @@ -9,6 +9,7 @@ import ( func MultiPanelList(ctx *gin.Context) { type multiPanelResponse struct { Id int `json:"id"` + Name string `json:"name"` Title *string `json:"title"` } @@ -25,10 +26,22 @@ func MultiPanelList(ctx *gin.Context) { var title *string if multiPanel.Embed != nil { title = multiPanel.Embed.Title + } else if multiPanel.UsesComponentsV2 { + t := "Components v2 message" + title = &t + } + + displayName := "Untitled multi-panel" + if title != nil && *title != "" { + displayName = *title + } + if multiPanel.Name != nil && *multiPanel.Name != "" { + displayName = *multiPanel.Name } data[i] = multiPanelResponse{ Id: multiPanel.Id, + Name: displayName, Title: title, } } diff --git a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go index 69938c00..336845c0 100644 --- a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go @@ -2,19 +2,24 @@ package api import ( "context" + "errors" "fmt" - "math" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" "github.com/TicketsBot-cloud/gdl/utils" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/log" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" + "go.uber.org/zap" ) +var errEmptyMultiPanel = errors.New("multi-panel message has nothing left to show") + type multiPanelMessageData struct { Footer footerPolicy @@ -24,6 +29,9 @@ type multiPanelMessageData struct { SelectMenuPlaceholder *string Embed *embed.Embed + + UsesComponentsV2 bool + Components []component.Component } func (d *multiPanelMessageData) applyFooter() { @@ -43,21 +51,37 @@ func multiPanelDiscordSubPanelError(action, detail string) string { } func multiPanelIntoMessageData(panel database.MultiPanel, footer footerPolicy) multiPanelMessageData { - blurple := types.Colour(0x5865f2) - custom := &types.CustomEmbed{Colour: &blurple} - if panel.Embed != nil && panel.Embed.CustomEmbed != nil { - custom = types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields) - } - - return multiPanelMessageData{ + data := multiPanelMessageData{ Footer: footer, ChannelId: panel.ChannelId, SelectMenu: panel.SelectMenu, SelectMenuPlaceholder: panel.SelectMenuPlaceholder, - Embed: custom.IntoDiscordEmbed(), } + + if panel.UsesComponentsV2 { + // Classic would edit a V2 message without its flag, which Discord refuses. + data.UsesComponentsV2 = true + + components, err := unmarshalComponents(panel.Components) + if err != nil { + log.Logger.Error("Failed to unmarshal multi-panel components", zap.Int("multi_panel_id", panel.Id), zap.Error(err)) + } else { + data.Components = components + } + } + + if !data.UsesComponentsV2 { + blurple := types.Colour(0x5865f2) + custom := &types.CustomEmbed{Colour: &blurple} + if panel.Embed != nil && panel.Embed.CustomEmbed != nil { + custom = types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields) + } + data.Embed = custom.IntoDiscordEmbed() + } + + return data } func getEffectiveLabel(panel database.Panel, customLabel *string) string { @@ -98,179 +122,264 @@ func getEffectiveEmojiAnimated(panel database.Panel, customEmojiName *string, cu return panel.EmojiAnimated } -func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.PanelWithCustomization) (uint64, error) { - d.applyFooter() +func effectiveDescription(description *string) *string { + if description != nil && *description == "" { + return nil + } + return description +} - var components []component.Component - if d.SelectMenu { - options := make([]component.SelectOption, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - options[i] = component.SelectOption{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - Value: pwc.CustomId, - Description: pwc.Description, - Emoji: emoji, - } +func buildPanelSelectOptions(panels []database.PanelWithCustomization) []component.SelectOption { + options := make([]component.SelectOption, len(panels)) + for i, pwc := range panels { + effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() + + options[i] = component.SelectOption{ + Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), + Value: pwc.CustomId, + Description: effectiveDescription(pwc.Description), + Emoji: emoji, } + } - var placeholder string - if d.SelectMenuPlaceholder == nil { - placeholder = "Select a topic..." - } else { - placeholder = *d.SelectMenuPlaceholder - } + return options +} - components = []component.Component{ - component.BuildActionRow( - component.BuildSelectMenu( - component.SelectMenu{ - CustomId: "multipanel", - Options: options, - Placeholder: placeholder, - MinValues: utils.IntPtr(1), - MaxValues: utils.IntPtr(1), - Disabled: false, - }), - ), - } - } else { - buttons := make([]component.Component, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - buttons[i] = component.BuildButton(component.Button{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - CustomId: pwc.CustomId, - Style: component.ButtonStyle(pwc.ButtonStyle), - Emoji: emoji, - Disabled: pwc.Disabled, - }) - } +func (d *multiPanelMessageData) buildPanelDropdown(panels []database.PanelWithCustomization) component.Component { + placeholder := "Select a topic..." + if d.SelectMenuPlaceholder != nil { + placeholder = *d.SelectMenuPlaceholder + } - var rows []component.Component - for i := 0; i <= int(math.Ceil(float64(len(buttons)/5))); i++ { - lb := i * 5 - ub := lb + 5 + return component.BuildSelectMenu(component.SelectMenu{ + CustomId: panelDropdownCustomId, + Options: buildPanelSelectOptions(panels), + Placeholder: placeholder, + MinValues: utils.IntPtr(1), + MaxValues: utils.IntPtr(1), + }) +} - if ub >= len(buttons) { - ub = len(buttons) - } +func (d *multiPanelMessageData) buildSystemRows(panels []database.PanelWithCustomization) []component.Component { + if len(panels) == 0 { + return nil + } - if lb >= ub { - break - } + if d.SelectMenu { + return []component.Component{component.BuildActionRow(d.buildPanelDropdown(panels))} + } + + var rows []component.Component + for start := 0; start < len(panels); start += maxActionRowChildren { + end := min(start+maxActionRowChildren, len(panels)) - row := component.BuildActionRow(buttons[lb:ub]...) - rows = append(rows, row) + buttons := make([]component.Component, 0, end-start) + for _, pwc := range panels[start:end] { + buttons = append(buttons, component.BuildButton(buildResolvedPanelButton(pwc))) } - components = rows + rows = append(rows, component.BuildActionRow(buttons...)) } - data := rest.CreateMessageData{ - Embeds: []*embed.Embed{d.Embed}, - Components: components, + return rows +} + +func (d *multiPanelMessageData) buildComponents(panels []database.PanelWithCustomization) (tree, system []component.Component) { + if !d.UsesComponentsV2 { + return nil, d.buildSystemRows(panels) } - // TODO: Use proper context - msg, err := rest.CreateMessage(context.Background(), ctx.Token, ctx.RateLimiter, d.ChannelId, data) - if err != nil { - return 0, err + byId := make(map[int]database.PanelWithCustomization, len(panels)) + for _, pwc := range panels { + byId[pwc.PanelId] = pwc } - return msg.Id, nil -} + placed := make(map[int]bool) + tree = mapComponentTree(d.Components, func(c component.Component) (component.Component, bool) { + switch data := c.ComponentData.(type) { + case component.Button: + if data.CustomId == "" { + return c, isResolvableLinkButton(data) + } -func (d *multiPanelMessageData) edit(ctx *botcontext.BotContext, messageId uint64, panels []database.PanelWithCustomization) error { - d.applyFooter() + panelId, ok := parsePanelPlacement(data.CustomId) + if !ok || data.Url != nil || placed[panelId] { + return component.Component{}, false + } - var components []component.Component - if d.SelectMenu { - options := make([]component.SelectOption, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - options[i] = component.SelectOption{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - Value: pwc.CustomId, - Description: pwc.Description, - Emoji: emoji, + pwc, ok := byId[panelId] + if !ok { + return component.Component{}, false } + + placed[panelId] = true + return component.BuildButton(buildResolvedPanelButton(pwc)), true + case component.SelectMenu: + return c, d.SelectMenu && data.CustomId == panelDropdownCustomId + case component.TextDisplay, component.MediaGallery, component.Separator, component.Thumbnail: + return c, true + default: + return component.Component{}, false } + }) - var placeholder string - if d.SelectMenuPlaceholder == nil { - placeholder = "Select a topic..." - } else { - placeholder = *d.SelectMenuPlaceholder + unplaced := make([]database.PanelWithCustomization, 0, len(panels)) + for _, pwc := range panels { + if !placed[pwc.PanelId] { + unplaced = append(unplaced, pwc) } + } - components = []component.Component{ - component.BuildActionRow( - component.BuildSelectMenu( - component.SelectMenu{ - CustomId: "multipanel", - Options: options, - Placeholder: placeholder, - MinValues: utils.IntPtr(1), - MaxValues: utils.IntPtr(1), - Disabled: false, - }), - ), + dropdownPlaced := false + tree = mapComponentTree(tree, func(c component.Component) (component.Component, bool) { + if _, ok := c.ComponentData.(component.SelectMenu); !ok { + return c, true } - } else { - buttons := make([]component.Component, len(panels)) - for i, pwc := range panels { - effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) - emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() - - buttons[i] = component.BuildButton(component.Button{ - Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), - CustomId: pwc.CustomId, - Style: component.ButtonStyle(pwc.ButtonStyle), - Emoji: emoji, - Disabled: pwc.Disabled, - }) + + if dropdownPlaced || len(unplaced) == 0 { + return component.Component{}, false } - var rows []component.Component - for i := 0; i <= int(math.Ceil(float64(len(buttons)/5))); i++ { - lb := i * 5 - ub := lb + 5 + dropdownPlaced = true + return d.buildPanelDropdown(unplaced), true + }) + + if dropdownPlaced { + return tree, nil + } + + return tree, d.buildSystemRows(unplaced) +} + +func isResolvableLinkButton(b component.Button) bool { + return b.Style == component.ButtonStyleLink && b.Url != nil && *b.Url != "" +} + +func mapComponentTree(nodes []component.Component, fn func(component.Component) (component.Component, bool)) []component.Component { + out := make([]component.Component, 0, len(nodes)) + + for _, c := range nodes { + switch data := c.ComponentData.(type) { + case component.Container: + data.Components = mapComponentTree(data.Components, fn) + if len(data.Components) == 0 { + continue + } + + c.ComponentData = data + case component.Section: + accessory, ok := fn(data.Accessory) + if !ok { + continue + } + + data.Components = mapComponentTree(data.Components, fn) + if len(data.Components) == 0 { + continue + } - if ub >= len(buttons) { - ub = len(buttons) + data.Accessory = accessory + c.ComponentData = data + case component.ActionRow: + data.Components = mapComponentTree(data.Components, fn) + if len(data.Components) == 0 { + continue } - if lb >= ub { - break + c.ComponentData = data + default: + resolved, ok := fn(c) + if !ok { + continue } - row := component.BuildActionRow(buttons[lb:ub]...) - rows = append(rows, row) + c = resolved } - components = rows + out = append(out, c) } - data := rest.EditMessageData{ + return out +} + +func (d *multiPanelMessageData) rendersEmpty(panels []database.PanelWithCustomization) bool { + tree, system := d.buildComponents(panels) + return d.UsesComponentsV2 && len(tree)+len(system) == 0 +} + +func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.PanelWithCustomization) (uint64, error) { + tree, system := d.buildComponents(panels) + + var data rest.CreateMessageData + if d.UsesComponentsV2 { + if len(tree)+len(system) == 0 { + return 0, errEmptyMultiPanel + } + + data = rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: append(tree, system...), + } + } else { + d.applyFooter() + data = rest.CreateMessageData{ + Embeds: []*embed.Embed{d.Embed}, + Components: system, + } + } + + // TODO: Use proper context + msg, err := rest.CreateMessage(context.Background(), ctx.Token, ctx.RateLimiter, d.ChannelId, data) + if err != nil { + return 0, err + } + + return msg.Id, nil +} + +func (d *multiPanelMessageData) buildEditData(components []component.Component) rest.EditMessageData { + if d.UsesComponentsV2 { + return v2EditData(components) + } + + d.applyFooter() + return rest.EditMessageData{ Embeds: []*embed.Embed{d.Embed}, Components: components, } +} + +func v2EditData(components []component.Component) rest.EditMessageData { + return rest.EditMessageData{ + Embeds: []*embed.Embed{}, + Flags: message.SumFlags(message.FlagComponentsV2), + Components: components, + AllowedMentions: &message.AllowedMention{}, + } +} + +func (d *multiPanelMessageData) edit(ctx *botcontext.BotContext, messageId uint64, panels []database.PanelWithCustomization) error { + tree, system := d.buildComponents(panels) + data := d.buildEditData(append(tree, system...)) _, err := rest.EditMessage(context.Background(), ctx.Token, ctx.RateLimiter, d.ChannelId, messageId, data) return err } + +func buildResolvedPanelButton(pwc database.PanelWithCustomization) component.Button { + effectiveEmojiName := getEffectiveEmoji(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiId := getEffectiveEmojiId(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + effectiveEmojiAnimated := getEffectiveEmojiAnimated(pwc.Panel, pwc.CustomEmojiName, pwc.CustomEmojiId) + emoji := types.NewEmoji(effectiveEmojiName, effectiveEmojiId, effectiveEmojiAnimated).IntoGdl() + + return component.Button{ + Label: getEffectiveLabel(pwc.Panel, pwc.CustomLabel), + CustomId: pwc.CustomId, + Style: component.ButtonStyle(pwc.ButtonStyle), + Emoji: emoji, + Disabled: pwc.Disabled, + } +} diff --git a/backend/app/http/endpoints/api/panel/multipanelresend.go b/backend/app/http/endpoints/api/panel/multipanelresend.go index 43592ef5..88353f34 100644 --- a/backend/app/http/endpoints/api/panel/multipanelresend.go +++ b/backend/app/http/endpoints/api/panel/multipanelresend.go @@ -58,16 +58,6 @@ func MultiPanelResend(ctx *gin.Context) { return } - // delete old message - // TODO: Use proper context - if err := rest.DeleteMessage(context.Background(), botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId); err != nil { - var unwrapped request.RestError - if errors.As(err, &unwrapped) && !unwrapped.IsClientError() { - ctx.JSON(500, utils.ErrorStr("Failed to send message. Please try again.")) - return - } - } - footer, err := footerPolicyForGuild(ctx, guildId, botContext) if err != nil { ctx.JSON(500, utils.ErrorStr("Unable to verify premium status. Please try again.")) @@ -80,8 +70,23 @@ func MultiPanelResend(ctx *gin.Context) { return } - // send new message messageData := multiPanelIntoMessageData(multiPanel, footer) + if messageData.rendersEmpty(panels) { + ctx.JSON(400, utils.ErrorStr("This multi-panel has no panels or blocks left to show.")) + return + } + + // delete old message + // TODO: Use proper context + if err := rest.DeleteMessage(context.Background(), botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId); err != nil { + var unwrapped request.RestError + if errors.As(err, &unwrapped) && !unwrapped.IsClientError() { + ctx.JSON(500, utils.ErrorStr("Failed to send message. Please try again.")) + return + } + } + + // send new message messageId, err := messageData.send(botContext, panels) if err != nil { var unwrapped request.RestError diff --git a/backend/app/http/endpoints/api/panel/multipanelupdate.go b/backend/app/http/endpoints/api/panel/multipanelupdate.go index 3b9932db..28d4d0a4 100644 --- a/backend/app/http/endpoints/api/panel/multipanelupdate.go +++ b/backend/app/http/endpoints/api/panel/multipanelupdate.go @@ -3,7 +3,6 @@ package api import ( "context" "errors" - "fmt" "net/http" "strconv" @@ -13,9 +12,9 @@ import ( "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/jackc/pgx/v4" "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/app/http/audit" - "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" "github.com/ticketsbot-cloud/dashboard/backend/utils" @@ -33,7 +32,8 @@ func MultiPanelUpdate(c *gin.Context) { // parse body var data multiPanelCreateData - if err := c.ShouldBindJSON(&data); err != nil { + keys, err := bindJSONWithKeys(c, &data) + if err != nil { c.JSON(400, utils.ErrorStr("Invalid request body: malformed JSON")) return } @@ -64,6 +64,29 @@ func MultiPanelUpdate(c *gin.Context) { return } + if err := data.keepStoredWhenAbsent(keys, multiPanel); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + return + } + + botContext, err := botcontext.ContextForGuild(guildId) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) + return + } + + if _, ok := componentsV2Gate(c, userId, guildId, botContext, data.entersComponentsV2(multiPanel), data.UsesComponentsV2, nil, nil, "Failed to verify premium status"); !ok { + return + } + + if data.UsesComponentsV2 { + data.Embed = classicEmbedForV2(data.Embed) + data.stripUnknownPlacements() + } else if data.Embed == nil { + c.JSON(400, utils.ErrorStr("An embed is required for classic messages.")) + return + } + if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if ok := errors.As(err, &validationErrors); !ok { @@ -76,41 +99,23 @@ func MultiPanelUpdate(c *gin.Context) { return } + name, err := normaliseMultiPanelName(data.Name) + if err != nil { + respondValidationError(c, err, "Failed to update multi-panel") + return + } + // validate body & get sub-panels panels, err := data.doValidations(guildId) if err != nil { - var validationError *validation.InvalidInputError - if errors.As(err, &validationError) { - c.JSON(400, utils.ErrorStr("%s", validationError.Error())) - } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) - } + respondValidationError(c, err, "Failed to update multi-panel") return } - // Validate labels for dropdown mode if data.SelectMenu { - for _, panel := range panels { - var panelConfig *panelConfiguration - for _, cfg := range data.Panels { - if panel.PanelId == cfg.PanelId { - panelConfig = &cfg - break - } - } - - var effectiveLabel string - if panelConfig != nil { - effectiveLabel = getEffectiveLabelForValidation(panel.ButtonLabel, panelConfig.CustomLabel) - } else { - effectiveLabel = panel.ButtonLabel - } - - if effectiveLabel == "" { - formatted := fmt.Sprintf("Panel '%s' must have a label when using dropdown mode. Please add a custom label or ensure the panel has a button label.", panel.Title) - c.JSON(400, utils.ErrorStr("%s", formatted)) - return - } + if err := validateDropdownLabels(panels, data.Panels); err != nil { + respondValidationError(c, err, "Failed to update multi-panel") + return } } @@ -129,10 +134,8 @@ func MultiPanelUpdate(c *gin.Context) { } } - // get bot context - botContext, err := botcontext.ContextForGuild(guildId) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) + if err := validateMultiPanelComponents(data); err != nil { + respondValidationError(c, err, "Failed to update multi-panel") return } @@ -154,23 +157,15 @@ func MultiPanelUpdate(c *gin.Context) { } } + if data.UsesComponentsV2 { + data.Components = normaliseComponentTree(data.Components) + } + messageData := data.IntoMessageData(footer) var messageId uint64 - // Check if channel changed - if multiPanel.ChannelId != data.ChannelId { - ctx, cancel := app.DefaultContext() - defer cancel() - - if err := rest.DeleteMessage(ctx, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId); err != nil { - var unwrapped request.RestError - if !errors.As(err, &unwrapped) || !unwrapped.IsClientError() { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) - return - } - } - cancel() - + reposted := resendsMessage(multiPanel.ChannelId, data.ChannelId, multiPanel.UsesComponentsV2, data.UsesComponentsV2) + if reposted { messageId, err = messageData.send(botContext, panelsWithCustom) if err != nil { var unwrapped request.RestError @@ -191,13 +186,8 @@ func MultiPanelUpdate(c *gin.Context) { err = messageData.edit(botContext, multiPanel.MessageId, panelsWithCustom) if err != nil { var unwrapped request.RestError - // Gone (404/10008), or authored by a different bot and so uneditable (50005): - // delete the old message if we can (ignoring failure) and resend. - if errors.As(err, &unwrapped) && (unwrapped.StatusCode == 404 || unwrapped.StatusCode == 10008 || unwrapped.ApiError.Code == 50005) { - delCtx, delCancel := app.DefaultContext() - _ = rest.DeleteMessage(delCtx, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId) - delCancel() - + if shouldRepost(err, !messageData.UsesComponentsV2, liveComponentsV2(botContext, multiPanel.ChannelId, multiPanel.MessageId)) { + reposted = true messageId, err = messageData.send(botContext, panelsWithCustom) if err != nil { var unwrapped2 request.RestError @@ -228,8 +218,25 @@ func MultiPanelUpdate(c *gin.Context) { } } + stored := false + defer func() { + if reposted && !stored { + discardMessage(botContext, data.ChannelId, messageId) + } + }() + // update DB - dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + componentsJSON, err := componentsToStore(data.UsesComponentsV2, data.Components, multiPanel.Components) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + return + } + + dbEmbed := embedIntoDatabase(data.Embed) + if data.UsesComponentsV2 && dbEmbed == nil { + dbEmbed = multiPanel.Embed + } + updated := database.MultiPanel{ Id: multiPanel.Id, MessageId: messageId, @@ -237,17 +244,40 @@ func MultiPanelUpdate(c *gin.Context) { GuildId: guildId, SelectMenu: data.SelectMenu, SelectMenuPlaceholder: data.SelectMenuPlaceholder, - Embed: &database.CustomEmbedWithFields{ - CustomEmbed: dbEmbed, - Fields: dbEmbedFields, - }, + Embed: dbEmbed, + UsesComponentsV2: data.UsesComponentsV2, + Components: componentsJSON, + Name: name, } - if err = dbclient.Client.MultiPanels.Update(c, multiPanel.Id, updated); err != nil { + err = dbclient.Client.MultiPanels.BeginFunc(c, func(tx pgx.Tx) error { + if err := dbclient.Client.MultiPanels.UpdateWithTx(c, tx, multiPanel.Id, updated); err != nil { + return err + } + + if err := dbclient.Client.MultiPanels.UpdateComponentsV2WithTx(c, tx, multiPanel.Id, updated.UsesComponentsV2, updated.Components); err != nil { + return err + } + + if err := dbclient.Client.MultiPanels.SetNameWithTx(c, tx, multiPanel.Id, updated.Name); err != nil { + return err + } + + return nil + }) + + if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) return } + stored = true + if reposted { + ctx, cancel := app.DefaultContext() + _ = rest.DeleteMessage(ctx, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId) + cancel() + } + // TODO: one query for ACID purposes // delete old targets if err := dbclient.Client.MultiPanelTargets.DeleteAll(c, multiPanel.Id); err != nil { diff --git a/backend/app/http/endpoints/api/panel/panelcreate.go b/backend/app/http/endpoints/api/panel/panelcreate.go index da5785f9..f7766334 100644 --- a/backend/app/http/endpoints/api/panel/panelcreate.go +++ b/backend/app/http/endpoints/api/panel/panelcreate.go @@ -38,6 +38,7 @@ type PanelAutoCloseBody struct { type panelBody struct { ChannelId uint64 `json:"channel_id,string"` MessageId uint64 `json:"message_id,string"` + Name *string `json:"name"` Title string `json:"title"` Content string `json:"content"` Colour *uint32 `json:"colour" validate:"omitempty,max=16777215"` @@ -79,22 +80,29 @@ type panelBody struct { SupportCanType bool `json:"support_can_type"` AutoClose PanelAutoCloseBody `json:"auto_close"` CloseReasons *database.PanelCloseReasons `json:"close_reasons"` + + MessageUsesComponentsV2 bool `json:"message_uses_components_v2"` + MessageComponents []component.Component `json:"message_components"` + WelcomeMessageUsesComponentsV2 bool `json:"welcome_message_uses_components_v2"` + WelcomeMessageComponents []component.Component `json:"welcome_message_components"` } func (p *panelBody) IntoPanelMessageData(customId string, showBranding bool) panelMessageData { return panelMessageData{ - ChannelId: p.ChannelId, - Title: p.Title, - Content: p.Content, - CustomId: customId, - Colour: utils.ConvertPtr[int](p.Colour), - ImageUrl: p.ImageUrl, - ThumbnailUrl: p.ThumbnailUrl, - Emoji: p.getEmoji(), - ButtonStyle: p.ButtonStyle, - ButtonLabel: p.ButtonLabel, - ButtonDisabled: p.Disabled, - ShowBranding: showBranding, + ChannelId: p.ChannelId, + Title: p.Title, + Content: p.Content, + CustomId: customId, + Colour: utils.ConvertPtr[int](p.Colour), + ImageUrl: p.ImageUrl, + ThumbnailUrl: p.ThumbnailUrl, + Emoji: p.getEmoji(), + ButtonStyle: p.ButtonStyle, + ButtonLabel: p.ButtonLabel, + ButtonDisabled: p.Disabled, + ShowBranding: showBranding, + UsesComponentsV2: p.MessageUsesComponentsV2, + Components: p.MessageComponents, } } @@ -117,29 +125,33 @@ func CreatePanel(c *gin.Context) { var data panelBody - if err := c.ShouldBindJSON(&data); err != nil { + if err := bindPanelCreateBody(c, &data); err != nil { c.JSON(400, utils.ErrorStr("Invalid request data. Please check your input and try again.")) return } data.MessageId = 0 - // Check panel quota quotaTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, false, botContext.Token, botContext.RateLimiter) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) return } + hasPaid, ok := componentsV2Gate(c, userId, guildId, botContext, data.entersComponentsV2(database.Panel{}), data.usesComponentsV2(), nil, "aTier, "Failed to verify premium status") + if !ok { + return + } + if quotaTier == premium.None { - panels, err := dbclient.Client.Panel.GetByGuild(c, guildId) + panelCount, err := dbclient.Client.Panel.GetPanelCount(c, guildId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch existing panels")) return } - if len(panels) >= freePanelLimit { - c.JSON(402, utils.ErrorStr("Panel quota exceeded: You have %d/%d panels. Purchase premium to unlock more panels.", len(panels), freePanelLimit)) + if panelCount >= freePanelLimit { + c.JSON(402, utils.ErrorStr("Panel quota exceeded: You have %d/%d panels. Purchase premium to unlock more panels.", panelCount, freePanelLimit)) return } } @@ -148,6 +160,11 @@ func CreatePanel(c *gin.Context) { ApplyPanelDefaults(&data) normaliseCloseReasons(&data) + if data.MessageUsesComponentsV2 { + data.ImageUrl = validClassicUrlOrNil(data.ImageUrl) + data.ThumbnailUrl = validClassicUrlOrNil(data.ThumbnailUrl) + } + // Not in DefaultApplicators: that also runs on update, where an empty list means "delete // every rule" rather than "unconfigured". if data.AccessControlList == nil || len(*data.AccessControlList) == 0 { @@ -185,12 +202,13 @@ func CreatePanel(c *gin.Context) { // Do custom validation validationContext := PanelValidationContext{ - Data: data, - GuildId: guildId, - IsPremium: featureTier > premium.None, - BotContext: botContext, - Channels: channels, - Roles: roles, + Data: data, + GuildId: guildId, + IsPremium: featureTier > premium.None, + HasPaidPremium: hasPaid, + BotContext: botContext, + Channels: channels, + Roles: roles, } if err := ValidatePanelBody(validationContext); err != nil { @@ -208,6 +226,8 @@ func CreatePanel(c *gin.Context) { data.TicketNotificationChannel = nil } + data.dropHiddenClassicEmbed() + // Do tag validation if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors @@ -233,6 +253,13 @@ func CreatePanel(c *gin.Context) { return } + if data.MessageUsesComponentsV2 { + data.MessageComponents = normaliseComponentTree(data.MessageComponents) + } + if data.WelcomeMessageUsesComponentsV2 { + data.WelcomeMessageComponents = normaliseComponentTree(data.WelcomeMessageComponents) + } + messageData := data.IntoPanelMessageData(customId, footer.ShowBranding) msgId, err := messageData.send(botContext) if err != nil { @@ -250,6 +277,13 @@ func CreatePanel(c *gin.Context) { return } + stored := false + defer func() { + if !stored { + discardMessage(botContext, data.ChannelId, msgId) + } + }() + var emojiId *uint64 var emojiName *string var emojiAnimated bool @@ -265,10 +299,11 @@ func CreatePanel(c *gin.Context) { } } - // Store welcome message embed first + welcomeEmbed := data.WelcomeMessage + var welcomeMessageEmbed *int - if data.WelcomeMessage != nil { - embed, fields := data.WelcomeMessage.IntoDatabaseStruct() + if welcomeEmbed != nil { + embed, fields := welcomeEmbed.IntoDatabaseStruct() embed.GuildId = guildId id, err := dbclient.Client.Embeds.CreateWithFields(c, embed, fields) @@ -280,6 +315,24 @@ func CreatePanel(c *gin.Context) { welcomeMessageEmbed = &id } + var welcomeMessageComponents *string + if data.WelcomeMessageUsesComponentsV2 { + welcomeMessageComponents, err = marshalComponents(data.WelcomeMessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save welcome message components to database")) + return + } + } + + var messageComponents *string + if data.MessageUsesComponentsV2 { + messageComponents, err = marshalComponents(data.MessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save panel message components to database")) + return + } + } + // If ticket limit is 0, this panel has no limit of its own if data.TicketLimit != nil && *data.TicketLimit == 0 { data.TicketLimit = nil @@ -290,6 +343,7 @@ func CreatePanel(c *gin.Context) { MessageId: msgId, ChannelId: data.ChannelId, GuildId: guildId, + Name: data.trimmedName(), Title: data.Title, Content: data.Content, Colour: utils.ConvertPtr[int32](data.Colour), @@ -328,6 +382,11 @@ func CreatePanel(c *gin.Context) { FeedbackEnabled: data.FeedbackEnabled, SupportCanView: data.SupportCanView, SupportCanType: data.SupportCanType, + + MessageUsesComponentsV2: data.MessageUsesComponentsV2, + MessageComponents: messageComponents, + WelcomeMessageUsesComponentsV2: data.WelcomeMessageUsesComponentsV2, + WelcomeMessageComponents: welcomeMessageComponents, } createOptions := panelCreateOptions{ @@ -367,6 +426,7 @@ func CreatePanel(c *gin.Context) { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save panel to database")) return } + stored = true if err := dbclient.Client.PanelTicketPermissions.Set(c, panelId, data.TicketPermissions); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save panel ticket permissions")) diff --git a/backend/app/http/endpoints/api/panel/paneldelete.go b/backend/app/http/endpoints/api/panel/paneldelete.go index a15c8d50..5545143e 100644 --- a/backend/app/http/endpoints/api/panel/paneldelete.go +++ b/backend/app/http/endpoints/api/panel/paneldelete.go @@ -2,7 +2,6 @@ package api import ( "errors" - "fmt" "net/http" "strconv" @@ -48,7 +47,7 @@ func DeletePanel(c *gin.Context) { } if panel.PanelId == 0 { - c.JSON(404, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + c.JSON(404, utils.ErrorStr("Panel not found: %d", panelId)) return } @@ -123,6 +122,11 @@ func DeletePanel(c *gin.Context) { messageData := multiPanelIntoMessageData(multiPanel, footer) messageId, err := messageData.send(botContext, panels) + if errors.Is(err, errEmptyMultiPanel) { + _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId) + continue + } + if err != nil { var unwrapped request.RestError if !errors.As(err, &unwrapped) || !unwrapped.IsClientError() { diff --git a/backend/app/http/endpoints/api/panel/panellist.go b/backend/app/http/endpoints/api/panel/panellist.go index 79069800..e492012a 100644 --- a/backend/app/http/endpoints/api/panel/panellist.go +++ b/backend/app/http/endpoints/api/panel/panellist.go @@ -17,6 +17,7 @@ func ListPanels(c *gin.Context) { type panelResponse struct { PanelId int `json:"panel_id"` ChannelId uint64 `json:"channel_id,string"` + Name string `json:"name"` Title string `json:"title"` Colour *int32 `json:"colour"` ButtonLabel string `json:"button_label"` @@ -42,7 +43,7 @@ func ListPanels(c *gin.Context) { g.Go(func() error { var err error - panels, err = dbclient.Client.Panel.GetByGuild(ctx, guildId) + panels, err = dbclient.Client.Panel.GetByGuildWithoutComponents(ctx, guildId) return err }) @@ -119,6 +120,11 @@ func ListPanels(c *gin.Context) { for i, p := range panels { hasSH := supportHoursSet[p.PanelId] + displayName := p.Title + if p.Name != nil && *p.Name != "" { + displayName = *p.Name + } + panelCloseReasons, ok := closeReasons[p.PanelId] if !ok { panelCloseReasons = database.DefaultPanelCloseReasons() @@ -127,6 +133,7 @@ func ListPanels(c *gin.Context) { wrapped[i] = panelResponse{ PanelId: p.PanelId, ChannelId: p.ChannelId, + Name: displayName, Title: p.Title, Colour: p.Colour, ButtonLabel: p.ButtonLabel, diff --git a/backend/app/http/endpoints/api/panel/panelmessagedata.go b/backend/app/http/endpoints/api/panel/panelmessagedata.go index e20e0604..ecdfd374 100644 --- a/backend/app/http/endpoints/api/panel/panelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/panelmessagedata.go @@ -1,18 +1,26 @@ package api import ( + "context" + "encoding/json" + "errors" "fmt" + "time" "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects" "github.com/TicketsBot-cloud/gdl/objects/channel/embed" + "github.com/TicketsBot-cloud/gdl/objects/channel/message" "github.com/TicketsBot-cloud/gdl/objects/guild/emoji" "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/gdl/rest/request" "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/log" "github.com/ticketsbot-cloud/dashboard/backend/utils" + "go.uber.org/zap" ) type panelMessageData struct { @@ -26,6 +34,44 @@ type panelMessageData struct { ButtonLabel string ButtonDisabled bool ShowBranding bool + + UsesComponentsV2 bool + Components []component.Component +} + +func marshalComponents(components []component.Component) (*string, error) { + if components == nil { + components = []component.Component{} + } + + raw, err := json.Marshal(components) + if err != nil { + return nil, err + } + + s := string(raw) + return &s, nil +} + +func componentsToStore(usesComponentsV2 bool, tree []component.Component, stored *string) (*string, error) { + if !usesComponentsV2 { + return stored, nil + } + + return marshalComponents(tree) +} + +func unmarshalComponents(raw *string) ([]component.Component, error) { + if raw == nil { + return nil, nil + } + + var components []component.Component + if err := json.Unmarshal([]byte(*raw), &components); err != nil { + return nil, err + } + + return components, nil } func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageData { @@ -43,7 +89,7 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD } } - return panelMessageData{ + data := panelMessageData{ ChannelId: panel.ChannelId, Title: panel.Title, Content: panel.Content, @@ -57,9 +103,23 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD ButtonDisabled: panel.Disabled, ShowBranding: showBranding, } + + if panel.MessageUsesComponentsV2 { + // Classic would edit a V2 message without its flag, which Discord refuses. + data.UsesComponentsV2 = true + + components, err := unmarshalComponents(panel.MessageComponents) + if err != nil { + log.Logger.Error("Failed to unmarshal panel message components", zap.Int("panel_id", panel.PanelId), zap.Error(err)) + } else { + data.Components = components + } + } + + return data } -func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { +func (p *panelMessageData) buildEmbed() *embed.Embed { e := embed.NewEmbed(). SetTitle(p.Title). SetDescription(p.Content). @@ -77,23 +137,60 @@ func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) } - data := rest.CreateMessageData{ - Embeds: []*embed.Embed{e}, - Components: []component.Component{ - component.BuildActionRow(component.BuildButton(component.Button{ - Label: p.ButtonLabel, - CustomId: p.CustomId, - Style: p.ButtonStyle, - Emoji: p.Emoji, - Url: nil, - Disabled: p.ButtonDisabled, - })), - }, + return e +} + +func (p *panelMessageData) button() component.Button { + return component.Button{ + Label: p.ButtonLabel, + CustomId: p.CustomId, + Style: p.ButtonStyle, + Emoji: p.Emoji, + Url: nil, + Disabled: p.ButtonDisabled, + } +} + +func (p *panelMessageData) systemButton() component.Component { + return component.BuildActionRow(component.BuildButton(p.button())) +} + +func (p *panelMessageData) v2Components() []component.Component { + placed := false + tree := mapComponentTree(p.Components, func(c component.Component) (component.Component, bool) { + if b, ok := c.ComponentData.(component.Button); ok && b.Url == nil && b.CustomId == ownPanelCustomId { + placed = true + return component.BuildButton(p.button()), true + } + + return c, true + }) + + if placed { + return tree } + return append(tree, p.systemButton()) +} + +func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { ctx, cancel := app.DefaultContext() defer cancel() + var data rest.CreateMessageData + + if p.UsesComponentsV2 { + data = rest.CreateMessageData{ + Flags: message.SumFlags(message.FlagComponentsV2), + Components: p.v2Components(), + } + } else { + data = rest.CreateMessageData{ + Embeds: []*embed.Embed{p.buildEmbed()}, + Components: []component.Component{p.systemButton()}, + } + } + msg, err := rest.CreateMessage(ctx, c.Token, c.RateLimiter, p.ChannelId, data) if err != nil { return 0, err @@ -102,41 +199,57 @@ func (p *panelMessageData) send(c *botcontext.BotContext) (uint64, error) { return msg.Id, nil } -func (p *panelMessageData) edit(c *botcontext.BotContext, messageId uint64) error { - e := embed.NewEmbed(). - SetTitle(p.Title). - SetDescription(p.Content). - SetColorPtr(p.Colour) +func (p *panelMessageData) buildEditData() rest.EditMessageData { + if p.UsesComponentsV2 { + return v2EditData(p.v2Components()) + } - if p.ImageUrl != nil { - e.SetImage(*p.ImageUrl) + return rest.EditMessageData{ + Embeds: []*embed.Embed{p.buildEmbed()}, + Components: []component.Component{p.systemButton()}, } +} - if p.ThumbnailUrl != nil { - e.SetThumbnail(*p.ThumbnailUrl) +func (p *panelMessageData) edit(c *botcontext.BotContext, messageId uint64) error { + ctx, cancel := app.DefaultContext() + defer cancel() + + data := p.buildEditData() + + _, err := rest.EditMessage(ctx, c.Token, c.RateLimiter, p.ChannelId, messageId, data) + return err +} + +// A failed save can leave a V2 message under a Classic row, which only a repost can fix. +func shouldRepost(err error, classicEdit bool, isComponentsV2 func() bool) bool { + var unwrapped request.RestError + if !errors.As(err, &unwrapped) { + return false } - if p.ShowBranding { - e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) + if unwrapped.StatusCode == 404 || unwrapped.StatusCode == 10008 || unwrapped.ApiError.Code == 50005 { + return true } - data := rest.EditMessageData{ - Embeds: []*embed.Embed{e}, - Components: []component.Component{ - component.BuildActionRow(component.BuildButton(component.Button{ - Label: p.ButtonLabel, - CustomId: p.CustomId, - Style: p.ButtonStyle, - Emoji: p.Emoji, - Url: nil, - Disabled: p.ButtonDisabled, - })), - }, + return classicEdit && unwrapped.StatusCode == 400 && isComponentsV2() +} + +func liveComponentsV2(c *botcontext.BotContext, channelId, messageId uint64) func() bool { + return func() bool { + ctx, cancel := app.DefaultContext() + defer cancel() + + msg, err := rest.GetChannelMessage(ctx, c.Token, c.RateLimiter, channelId, messageId) + return err == nil && msg.Flags&int(message.FlagComponentsV2) != 0 } +} - ctx, cancel := app.DefaultContext() +// Uses its own context because the request's may already be cancelled. +func discardMessage(c *botcontext.BotContext, channelId, messageId uint64) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := rest.EditMessage(ctx, c.Token, c.RateLimiter, p.ChannelId, messageId, data) - return err + if err := rest.DeleteMessage(ctx, c.Token, c.RateLimiter, channelId, messageId); err != nil { + log.Logger.Warn("Failed to delete orphaned panel message", zap.Uint64("channel_id", channelId), zap.Uint64("message_id", messageId), zap.Error(err)) + } } diff --git a/backend/app/http/endpoints/api/panel/panelname_test.go b/backend/app/http/endpoints/api/panel/panelname_test.go new file mode 100644 index 00000000..04bc5064 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/panelname_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPanelName(t *testing.T) { + str := func(s string) *string { return &s } + + tests := []struct { + name string + in *string + want *string + wantErr bool + }{ + {name: "omitted", in: nil, want: nil}, + {name: "empty", in: str(""), want: nil}, + {name: "whitespace only", in: str(" \t "), want: nil}, + {name: "trimmed", in: str(" Billing "), want: str("Billing")}, + {name: "exactly 100 characters", in: str(strings.Repeat("a", 100)), want: str(strings.Repeat("a", 100))}, + {name: "100 multi-byte characters", in: str(strings.Repeat("é", 100)), want: str(strings.Repeat("é", 100))}, + {name: "101 characters", in: str(strings.Repeat("a", 101)), wantErr: true}, + {name: "null byte", in: str("Bill\x00ing"), wantErr: true}, + {name: "100 characters plus padding", in: str(" " + strings.Repeat("a", 100) + " "), want: str(strings.Repeat("a", 100))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := panelBody{Name: tt.in} + + err := validateName(PanelValidationContext{Data: data})() + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, data.trimmedName()) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/panelresend.go b/backend/app/http/endpoints/api/panel/panelresend.go index 07d7dc24..153ac822 100644 --- a/backend/app/http/endpoints/api/panel/panelresend.go +++ b/backend/app/http/endpoints/api/panel/panelresend.go @@ -3,7 +3,6 @@ package api import ( "context" "errors" - "fmt" "strconv" "github.com/TicketsBot-cloud/common/featureflags" @@ -46,7 +45,7 @@ func ResendPanel(ctx *gin.Context) { } if panel.PanelId == 0 { - ctx.JSON(404, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + ctx.JSON(404, utils.ErrorStr("Panel not found: %d", panelId)) return } diff --git a/backend/app/http/endpoints/api/panel/panelupdate.go b/backend/app/http/endpoints/api/panel/panelupdate.go index 570c7c46..0a86b9fd 100644 --- a/backend/app/http/endpoints/api/panel/panelupdate.go +++ b/backend/app/http/endpoints/api/panel/panelupdate.go @@ -36,6 +36,20 @@ func multiPanelSyncError(multiPanelId int, action, detail string) string { ) } +func abortPanelMessageError(c *gin.Context, err error) { + var unwrapped request.RestError + if errors.As(err, &unwrapped) && unwrapped.IsClientError() { + c.JSON(400, utils.ErrorStr("Failed to update panel message: %s", unwrapped.Error())) + return + } + + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) +} + +func resendsMessage(storedChannel, channel uint64, storedV2, usesV2 bool) bool { + return storedChannel != channel || (storedV2 && !usesV2) +} + func UpdatePanel(c *gin.Context) { guildId := c.Keys["guildid"].(uint64) userId := c.Keys["userid"].(uint64) @@ -52,7 +66,8 @@ func UpdatePanel(c *gin.Context) { } var data panelBody - if err := c.ShouldBindJSON(&data); err != nil { + keys, err := bindJSONWithKeys(c, &data) + if err != nil { c.JSON(400, utils.ErrorStr("Invalid request body: malformed JSON")) return } @@ -76,6 +91,11 @@ func UpdatePanel(c *gin.Context) { return } + if err := data.mergeStored(keys, existing); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + existingAcl, err := dbclient.Client.PanelAccessControlRules.GetAll(c, panelId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load panel")) @@ -96,6 +116,17 @@ func UpdatePanel(c *gin.Context) { return } + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + + hasPaid, ok := componentsV2Gate(c, userId, guildId, botContext, data.entersComponentsV2(existing), data.usesComponentsV2(), &premiumTier, nil, "Failed to update panel") + if !ok { + return + } + // Apply defaults ApplyPanelDefaults(&data) normaliseCloseReasons(&data) @@ -103,12 +134,6 @@ func UpdatePanel(c *gin.Context) { closeReasonsChanged := data.CloseReasons != nil && (data.CloseReasons.AllowCustom != existingCloseReasons.AllowCustom || !slices.Equal(data.CloseReasons.Reasons, existingCloseReasons.Reasons)) - premiumTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) - return - } - footer, err := footerPolicyForGuild(c, guildId, botContext) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) @@ -135,12 +160,13 @@ func UpdatePanel(c *gin.Context) { // Do custom validation validationContext := PanelValidationContext{ - Data: data, - GuildId: guildId, - IsPremium: premiumTier > premium.None, - BotContext: botContext, - Channels: channels, - Roles: roles, + Data: data, + GuildId: guildId, + IsPremium: premiumTier > premium.None, + HasPaidPremium: hasPaid, + BotContext: botContext, + Channels: channels, + Roles: roles, } if err := ValidatePanelBody(validationContext); err != nil { @@ -158,6 +184,8 @@ func UpdatePanel(c *gin.Context) { data.TicketNotificationChannel = nil } + data.dropHiddenClassicEmbed() + // Do tag validation if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors @@ -186,12 +214,18 @@ func UpdatePanel(c *gin.Context) { } } + if data.MessageUsesComponentsV2 { + data.MessageComponents = normaliseComponentTree(data.MessageComponents) + } + if data.WelcomeMessageUsesComponentsV2 { + data.WelcomeMessageComponents = normaliseComponentTree(data.WelcomeMessageComponents) + } + messageData := data.IntoPanelMessageData(existing.CustomId, footer.ShowBranding) var newMessageId uint64 - // Check if channel changed - if existing.ChannelId != data.ChannelId { - _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId) + reposted := resendsMessage(existing.ChannelId, data.ChannelId, existing.MessageUsesComponentsV2, data.MessageUsesComponentsV2) + if reposted { newMessageId, err = messageData.send(botContext) if err != nil { var unwrapped request.RestError @@ -203,11 +237,11 @@ func UpdatePanel(c *gin.Context) { // Swallow error // TODO: Make channel_id column nullable, and set to null } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + abortPanelMessageError(c, err) return } } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + abortPanelMessageError(c, err) return } } @@ -216,18 +250,15 @@ func UpdatePanel(c *gin.Context) { err = messageData.edit(botContext, existing.MessageId) if err != nil { var unwrapped request.RestError - // Message is gone (404/10008), or was authored by a different bot — e.g. the - // guild switched between the main bot and a whitelabel bot — and so cannot be - // edited (50005). Delete the old message if we can (ignoring failure) and resend. - if errors.As(err, &unwrapped) && (unwrapped.StatusCode == 404 || unwrapped.StatusCode == 10008 || unwrapped.ApiError.Code == 50005) { - _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId) + if shouldRepost(err, !messageData.UsesComponentsV2, liveComponentsV2(botContext, existing.ChannelId, existing.MessageId)) { + reposted = true newMessageId, err = messageData.send(botContext) if err != nil { var unwrapped2 request.RestError if errors.As(err, &unwrapped2) && unwrapped2.StatusCode == 403 { c.JSON(403, utils.ErrorStr("I do not have permission to send messages in the specified channel")) } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + abortPanelMessageError(c, err) } return @@ -236,7 +267,7 @@ func UpdatePanel(c *gin.Context) { c.JSON(403, utils.ErrorStr("I do not have permission to edit messages in the specified channel")) return } else { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + abortPanelMessageError(c, err) return } } else { @@ -244,42 +275,60 @@ func UpdatePanel(c *gin.Context) { } } - // Update welcome message - var welcomeMessageEmbed *int - if data.WelcomeMessage == nil { - if existing.WelcomeMessageEmbed != nil { // If welcome message wasn't null, but now is, delete the embed + stored := false + defer func() { + if reposted && !stored && newMessageId != 0 { + discardMessage(botContext, data.ChannelId, newMessageId) + } + }() + + welcomeEmbed := data.WelcomeMessage + + welcomeMessageEmbed := existing.WelcomeMessageEmbed + switch { + case welcomeEmbed == nil && !data.WelcomeMessageUsesComponentsV2: + if existing.WelcomeMessageEmbed != nil { if err := dbclient.Client.Embeds.Delete(c, *existing.WelcomeMessageEmbed); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) return } - } // else, welcomeMessageEmbed will be nil - } else { - // TODO: Upsert? Don't think we can, as no unique key in the table, panel_id is in panels table - if existing.WelcomeMessageEmbed == nil { // Create - embed, fields := data.WelcomeMessage.IntoDatabaseStruct() - embed.GuildId = guildId + } - id, err := dbclient.Client.Embeds.CreateWithFields(c, embed, fields) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) - return - } + welcomeMessageEmbed = nil + case welcomeEmbed != nil && existing.WelcomeMessageEmbed == nil: + embed, fields := welcomeEmbed.IntoDatabaseStruct() + embed.GuildId = guildId - welcomeMessageEmbed = &id - } else { // Update - welcomeMessageEmbed = existing.WelcomeMessageEmbed + id, err := dbclient.Client.Embeds.CreateWithFields(c, embed, fields) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } - embed, fields := data.WelcomeMessage.IntoDatabaseStruct() - embed.Id = *existing.WelcomeMessageEmbed - embed.GuildId = guildId + welcomeMessageEmbed = &id + case welcomeEmbed != nil: + embed, fields := welcomeEmbed.IntoDatabaseStruct() + embed.Id = *existing.WelcomeMessageEmbed + embed.GuildId = guildId - if err := dbclient.Client.Embeds.UpdateWithFields(c, embed, fields); err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) - return - } + if err := dbclient.Client.Embeds.UpdateWithFields(c, embed, fields); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return } } + welcomeMessageComponents, err := componentsToStore(data.WelcomeMessageUsesComponentsV2, data.WelcomeMessageComponents, existing.WelcomeMessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + + messageComponents, err := componentsToStore(data.MessageUsesComponentsV2, data.MessageComponents, existing.MessageComponents) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + // If ticket limit is 0, this panel has no limit of its own if data.TicketLimit != nil && *data.TicketLimit == 0 { data.TicketLimit = nil @@ -291,6 +340,7 @@ func UpdatePanel(c *gin.Context) { MessageId: newMessageId, ChannelId: data.ChannelId, GuildId: guildId, + Name: data.trimmedName(), Title: data.Title, Content: data.Content, Colour: utils.ConvertPtr[int32](data.Colour), @@ -329,6 +379,11 @@ func UpdatePanel(c *gin.Context) { FeedbackEnabled: data.FeedbackEnabled, SupportCanView: data.SupportCanView, SupportCanType: data.SupportCanType, + + MessageUsesComponentsV2: data.MessageUsesComponentsV2, + MessageComponents: messageComponents, + WelcomeMessageUsesComponentsV2: data.WelcomeMessageUsesComponentsV2, + WelcomeMessageComponents: welcomeMessageComponents, } // insert mention data @@ -361,6 +416,14 @@ func UpdatePanel(c *gin.Context) { return err } + if err := dbclient.Client.Panel.UpdateComponentsV2WithTx(c, tx, panel); err != nil { + return err + } + + if err := dbclient.Client.Panel.SetNameWithTx(c, tx, panel.PanelId, panel.Name); err != nil { + return err + } + if err := dbclient.Client.PanelUserMention.SetWithTx(c, tx, panel.PanelId, shouldMentionUser); err != nil { return err } @@ -403,6 +466,11 @@ func UpdatePanel(c *gin.Context) { return } + stored = true + if reposted { + _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId) + } + if err := dbclient.Client.PanelTicketPermissions.Set(c, panel.PanelId, data.TicketPermissions); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save panel ticket permissions")) return @@ -443,10 +511,7 @@ func UpdatePanel(c *gin.Context) { err = messageData.edit(botContext, multiPanel.MessageId, panels) if err != nil { var unwrapped request.RestError - // Gone (404/10008), or authored by a different bot and so uneditable (50005): - // delete the old message if we can (ignoring failure) and resend. - if errors.As(err, &unwrapped) && (unwrapped.StatusCode == 404 || unwrapped.StatusCode == 10008 || unwrapped.ApiError.Code == 50005) { - _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId) + if shouldRepost(err, !messageData.UsesComponentsV2, liveComponentsV2(botContext, multiPanel.ChannelId, multiPanel.MessageId)) { messageId, err = messageData.send(botContext, panels) if err != nil { var unwrapped2 request.RestError @@ -489,9 +554,12 @@ func UpdatePanel(c *gin.Context) { if messageId != multiPanel.MessageId { if err := dbclient.Client.MultiPanels.UpdateMessageId(c, multiPanel.Id, messageId); err != nil { + discardMessage(botContext, multiPanel.ChannelId, messageId) _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) return } + + _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, multiPanel.ChannelId, multiPanel.MessageId) } } diff --git a/backend/app/http/endpoints/api/panel/panelupdate_test.go b/backend/app/http/endpoints/api/panel/panelupdate_test.go new file mode 100644 index 00000000..51600a81 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/panelupdate_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/TicketsBot-cloud/gdl/rest/request" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/app" +) + +func TestResendsMessage(t *testing.T) { + tests := []struct { + name string + storedChannel, channel uint64 + storedV2, usesV2 bool + want bool + }{ + {name: "classic edits in place", storedChannel: 1, channel: 1}, + {name: "V2 edits in place", storedChannel: 1, channel: 1, storedV2: true, usesV2: true}, + {name: "classic to V2 edits in place", storedChannel: 1, channel: 1, usesV2: true}, + {name: "V2 back to classic resends", storedChannel: 1, channel: 1, storedV2: true, want: true}, + {name: "a channel change resends", storedChannel: 1, channel: 2, want: true}, + {name: "a channel change resends a V2 message", storedChannel: 1, channel: 2, storedV2: true, usesV2: true, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, resendsMessage(tt.storedChannel, tt.channel, tt.storedV2, tt.usesV2)) + }) + } +} + +func TestShouldRepost(t *testing.T) { + restError := func(status, code int) error { + return request.RestError{StatusCode: status, ApiError: request.ApiV8Error{Code: code}} + } + + tests := []struct { + name string + err error + classicEdit bool + liveV2 bool + want bool + wantLookups int + }{ + {name: "message gone", err: restError(404, 10008), classicEdit: true, want: true}, + {name: "another bot's message", err: restError(403, 50005), want: true}, + {name: "V2 message under a Classic row", err: restError(400, 50035), classicEdit: true, liveV2: true, want: true, wantLookups: 1}, + {name: "Classic message refused for its content", err: restError(400, 50035), classicEdit: true, wantLookups: 1}, + {name: "V2 edit refused", err: restError(400, 50035), liveV2: true}, + {name: "missing permissions", err: restError(403, 50013), classicEdit: true, liveV2: true}, + {name: "Discord unavailable", err: restError(502, 0), classicEdit: true, liveV2: true}, + {name: "transport error", err: errors.New("context deadline exceeded"), classicEdit: true, liveV2: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lookups := 0 + isComponentsV2 := func() bool { + lookups++ + return tt.liveV2 + } + + require.Equal(t, tt.want, shouldRepost(tt.err, tt.classicEdit, isComponentsV2)) + require.Equal(t, tt.wantLookups, lookups) + }) + } +} + +func TestAbortPanelMessageError(t *testing.T) { + gin.SetMode(gin.TestMode) + + invalidBody := request.RestError{ + StatusCode: 400, + ApiError: request.ApiV8Error{ + Code: 50035, + Message: "Invalid Form Body", + Errors: []request.FieldError{{ + FieldName: "components[0].content", + Code: "BASE_TYPE_MAX_LENGTH", + Message: "Must be 4000 or fewer in length.", + }}, + }, + } + + tests := []struct { + name string + err error + wantStatus int + wantError string + }{ + { + name: "Discord refuses the message", + err: invalidBody, + wantStatus: http.StatusBadRequest, + wantError: "Failed to update panel message: Invalid Form Body: components[0].content: Must be 4000 or fewer in length. (BASE_TYPE_MAX_LENGTH)", + }, + {name: "Discord unavailable", err: request.RestError{StatusCode: 502, ApiError: request.ApiV8Error{Message: "Bad Gateway"}}, wantStatus: http.StatusInternalServerError}, + {name: "transport error", err: errors.New("context deadline exceeded"), wantStatus: http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + abortPanelMessageError(c, tt.err) + require.Equal(t, tt.wantStatus, w.Code) + + if tt.wantStatus == http.StatusInternalServerError { + require.Len(t, c.Errors, 1) + + var apiError *app.ApiError + require.ErrorAs(t, c.Errors.Last(), &apiError) + require.Equal(t, "Failed to update panel", apiError.ExternalMessage) + return + } + + require.Empty(t, c.Errors) + + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, tt.wantError, body["error"]) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/permcheck.go b/backend/app/http/endpoints/api/panel/permcheck.go index e1a102d7..6e12689a 100644 --- a/backend/app/http/endpoints/api/panel/permcheck.go +++ b/backend/app/http/endpoints/api/panel/permcheck.go @@ -52,7 +52,7 @@ func PermCheckHandler(c *gin.Context) { g1.Go(func() error { var err error - panels, err = dbclient.Client.Panel.GetByGuild(ctx1, guildId) + panels, err = dbclient.Client.Panel.GetByGuildWithoutComponents(ctx1, guildId) return err }) @@ -126,9 +126,14 @@ func PermCheckHandler(c *gin.Context) { for _, panel := range panels { useThreads := panel.UseThreads + displayName := panel.Title + if panel.Name != nil && *panel.Name != "" { + displayName = *panel.Name + } + pr := panelPermResult{ PanelId: panel.PanelId, - PanelTitle: panel.Title, + PanelTitle: displayName, UseThreads: useThreads, Channels: make([]channelCheckResult, 0, 3), } diff --git a/backend/app/http/endpoints/api/panel/premiumgate.go b/backend/app/http/endpoints/api/panel/premiumgate.go new file mode 100644 index 00000000..e483d3f0 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/premiumgate.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "net/http" + + "github.com/TicketsBot-cloud/common/premium" + "github.com/TicketsBot-cloud/database" + "github.com/gin-gonic/gin" + "github.com/ticketsbot-cloud/dashboard/backend/app" + "github.com/ticketsbot-cloud/dashboard/backend/botcontext" + "github.com/ticketsbot-cloud/dashboard/backend/rpc" + "github.com/ticketsbot-cloud/dashboard/backend/utils" +) + +const ( + componentsV2PremiumMessage = "Components v2 messages require premium. Switch them back to Classic to save." + componentsV2UnavailableMessage = "Components v2 messages aren't available for this server yet. Switch them back to Classic to save." +) + +func hasPaidPremium(ctx context.Context, guildId uint64, botContext *botcontext.BotContext) (bool, error) { + tier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, false, botContext.Token, botContext.RateLimiter) + if err != nil { + return false, err + } + + return tier > premium.None, nil +} + +func componentsV2BuilderEnabled(ctx context.Context, userId, guildId uint64, tier premium.PremiumTier) bool { + attributes := utils.DashboardFlagAttributes(ctx, userId).WithGuild(guildId).WithPremiumTier(int8(tier)) + return utils.FeatureFlags.IsEnabled(ctx, "202608_COMPONENTS_V2_BUILDER", attributes) +} + +func componentsV2Gate(c *gin.Context, userId, guildId uint64, botContext *botcontext.BotContext, entering, usesV2 bool, votingTier, paidTier *premium.PremiumTier, internalMessage string) (hasPaid, ok bool) { + if entering { + // A paid tier is also the voting-inclusive one: voting only counts without paid premium. + if votingTier == nil && paidTier != nil && *paidTier > premium.None { + votingTier = paidTier + } + + if votingTier == nil { + tier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, internalMessage)) + return false, false + } + + votingTier = &tier + } + + if !componentsV2BuilderEnabled(c, userId, guildId, *votingTier) { + c.JSON(403, utils.ErrorStr(componentsV2UnavailableMessage)) + return false, false + } + } + + if paidTier != nil { + hasPaid = *paidTier > premium.None + } else if usesV2 { + paid, err := hasPaidPremium(c, guildId, botContext) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, internalMessage)) + return false, false + } + + hasPaid = paid + } + + if usesV2 && !hasPaid { + c.JSON(402, utils.ErrorStr(componentsV2PremiumMessage)) + return false, false + } + + return hasPaid, true +} + +func (p *panelBody) entersComponentsV2(stored database.Panel) bool { + return (p.MessageUsesComponentsV2 && !stored.MessageUsesComponentsV2) || + (p.WelcomeMessageUsesComponentsV2 && !stored.WelcomeMessageUsesComponentsV2) +} + +func (p *panelBody) usesComponentsV2() bool { + return p.MessageUsesComponentsV2 || p.WelcomeMessageUsesComponentsV2 +} + +func (d *multiPanelCreateData) entersComponentsV2(stored database.MultiPanel) bool { + return d.UsesComponentsV2 && !stored.UsesComponentsV2 +} diff --git a/backend/app/http/endpoints/api/panel/premiumgate_test.go b/backend/app/http/endpoints/api/panel/premiumgate_test.go new file mode 100644 index 00000000..930e97d3 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/premiumgate_test.go @@ -0,0 +1,247 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/TicketsBot-cloud/common/featureflags" + "github.com/TicketsBot-cloud/common/model" + "github.com/TicketsBot-cloud/common/premium" + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/rest/ratelimit" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/app" + "github.com/ticketsbot-cloud/dashboard/backend/botcontext" + "github.com/ticketsbot-cloud/dashboard/backend/config" + "github.com/ticketsbot-cloud/dashboard/backend/rpc" + "github.com/ticketsbot-cloud/dashboard/backend/utils" + "go.uber.org/zap" +) + +func TestPanelEntersComponentsV2(t *testing.T) { + classic := database.Panel{PanelId: 1} + v2Message := database.Panel{PanelId: 1, MessageUsesComponentsV2: true} + v2Welcome := database.Panel{PanelId: 1, WelcomeMessageUsesComponentsV2: true} + v2Both := database.Panel{PanelId: 1, MessageUsesComponentsV2: true, WelcomeMessageUsesComponentsV2: true} + + tests := []struct { + name string + data panelBody + stored database.Panel + want bool + }{ + {name: "create, classic", data: panelBody{}}, + {name: "create, v2 message", data: panelBody{MessageUsesComponentsV2: true}, want: true}, + {name: "create, v2 welcome", data: panelBody{WelcomeMessageUsesComponentsV2: true}, want: true}, + {name: "update, classic stays classic", data: panelBody{}, stored: classic}, + {name: "update, message switches to v2", data: panelBody{MessageUsesComponentsV2: true}, stored: classic, want: true}, + {name: "update, welcome switches to v2", data: panelBody{WelcomeMessageUsesComponentsV2: true}, stored: classic, want: true}, + {name: "update, v2 message stays v2", data: panelBody{MessageUsesComponentsV2: true}, stored: v2Message}, + {name: "update, v2 message goes back to classic", data: panelBody{}, stored: v2Message}, + {name: "update, welcome switches to v2 beside a v2 message", data: panelBody{MessageUsesComponentsV2: true, WelcomeMessageUsesComponentsV2: true}, stored: v2Message, want: true}, + {name: "update, message switches to v2 beside a v2 welcome", data: panelBody{MessageUsesComponentsV2: true, WelcomeMessageUsesComponentsV2: true}, stored: v2Welcome, want: true}, + {name: "update, both stay v2", data: panelBody{MessageUsesComponentsV2: true, WelcomeMessageUsesComponentsV2: true}, stored: v2Both}, + {name: "update, both go back to classic", data: panelBody{}, stored: v2Both}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.data.entersComponentsV2(tt.stored)) + }) + } +} + +func TestMultiPanelEntersComponentsV2(t *testing.T) { + classic := database.MultiPanel{Id: 1} + v2 := database.MultiPanel{Id: 1, UsesComponentsV2: true} + + tests := []struct { + name string + data multiPanelCreateData + stored database.MultiPanel + want bool + }{ + {name: "create, classic", data: multiPanelCreateData{}}, + {name: "create, v2", data: multiPanelCreateData{UsesComponentsV2: true}, want: true}, + {name: "update, classic stays classic", data: multiPanelCreateData{}, stored: classic}, + {name: "update, switches to v2", data: multiPanelCreateData{UsesComponentsV2: true}, stored: classic, want: true}, + {name: "update, stays v2", data: multiPanelCreateData{UsesComponentsV2: true}, stored: v2}, + {name: "update, goes back to classic", data: multiPanelCreateData{}, stored: v2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.data.entersComponentsV2(tt.stored)) + }) + } +} + +func stubPremiumClient(t *testing.T, client premium.IPremiumLookupClient) { + t.Helper() + + prev := rpc.PremiumClient + t.Cleanup(func() { rpc.PremiumClient = prev }) + rpc.PremiumClient = client +} + +func stubFlagGlobals(t *testing.T, ownerId uint64) { + t.Helper() + + prevFlags, prevOwner := utils.FeatureFlags, config.Conf.Owner + t.Cleanup(func() { utils.FeatureFlags, config.Conf.Owner = prevFlags, prevOwner }) + config.Conf.Owner = ownerId +} + +func offlineFlags(t *testing.T, ruleset string) *featureflags.Client { + t.Helper() + + client, err := featureflags.NewOffline(context.Background(), zap.NewNop(), ruleset, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + + return client +} + +type countingPremiumClient struct { + premium.MockLookupClient + lookups int + err error +} + +func (c *countingPremiumClient) GetTierByGuildId(ctx context.Context, guildId uint64, includeVoting bool, token string, ratelimiter *ratelimit.Ratelimiter) (premium.PremiumTier, error) { + c.lookups++ + if c.err != nil { + return premium.None, c.err + } + + return c.MockLookupClient.GetTierByGuildId(ctx, guildId, includeVoting, token, ratelimiter) +} + +func TestHasPaidPremium(t *testing.T) { + tests := []struct { + name string + client premium.MockLookupClient + want bool + }{ + {name: "voting only", client: premium.NewMockLookupClient(premium.Premium, model.EntitlementSourceVoting)}, + {name: "paid", client: premium.NewMockLookupClient(premium.Premium, model.EntitlementSourcePatreon), want: true}, + {name: "none", client: premium.NewMockLookupClient(premium.None, "")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubPremiumClient(t, &tt.client) + + got, err := hasPaidPremium(context.Background(), 1, &botcontext.BotContext{}) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestComponentsV2BuilderEnabled(t *testing.T) { + const userId = 42 + stubFlagGlobals(t, userId) + utils.FeatureFlags = offlineFlags(t, `{"202608_COMPONENTS_V2_BUILDER": {"defaultValue": false, "rules": [{"condition": {"guild_id": "7", "premium_tier": {"$gte": 0}}, "force": true}]}}`) + + ctx := context.Background() + require.True(t, componentsV2BuilderEnabled(ctx, userId, 7, premium.Premium)) + require.True(t, componentsV2BuilderEnabled(ctx, userId, 7, premium.Whitelabel)) + require.False(t, componentsV2BuilderEnabled(ctx, userId, 7, premium.None)) + require.False(t, componentsV2BuilderEnabled(ctx, userId, 8, premium.Premium)) +} + +func TestComponentsV2Gate(t *testing.T) { + gin.SetMode(gin.TestMode) + + const userId, guildId = 42, 7 + stubFlagGlobals(t, userId) + flagOn := offlineFlags(t, `{"202608_COMPONENTS_V2_BUILDER": {"defaultValue": false, "rules": [{"condition": {"premium_tier": {"$gte": 0}}, "force": true}]}}`) + flagOff := offlineFlags(t, `{"202608_COMPONENTS_V2_BUILDER": {"defaultValue": false}}`) + + paid := premium.NewMockLookupClient(premium.Premium, model.EntitlementSourcePatreon) + voting := premium.NewMockLookupClient(premium.Premium, model.EntitlementSourceVoting) + none := premium.NewMockLookupClient(premium.None, "") + known, unpaid := premium.Premium, premium.None + + v2Body := panelBody{MessageUsesComponentsV2: true} + v2Message := database.Panel{MessageUsesComponentsV2: true} + + tests := []struct { + name string + data panelBody + stored database.Panel + client premium.MockLookupClient + lookupErr error + flagOn bool + votingTier, paidTier *premium.PremiumTier + wantStatus int + wantPaid bool + wantLookups int + }{ + {name: "classic", client: none}, + {name: "back to classic without premium or the flag", stored: v2Message, client: none}, + {name: "entering with the flag off", data: v2Body, client: paid, wantStatus: http.StatusForbidden, wantLookups: 1}, + {name: "staying V2 with the flag off", data: v2Body, stored: v2Message, client: paid, wantPaid: true, wantLookups: 1}, + {name: "voting only", data: v2Body, client: voting, flagOn: true, wantStatus: http.StatusPaymentRequired, wantLookups: 2}, + {name: "welcome-only V2 with paid premium", data: panelBody{WelcomeMessageUsesComponentsV2: true}, client: paid, flagOn: true, wantPaid: true, wantLookups: 2}, + {name: "paid", data: v2Body, client: paid, flagOn: true, wantPaid: true, wantLookups: 2}, + {name: "a known paid tier answers both checks", data: v2Body, client: none, flagOn: true, paidTier: &known, wantPaid: true}, + {name: "a known unpaid tier still looks up the flag's tier", data: v2Body, client: voting, flagOn: true, paidTier: &unpaid, wantStatus: http.StatusPaymentRequired, wantLookups: 1}, + {name: "a known voting tier answers the flag check", data: v2Body, client: paid, flagOn: true, votingTier: &known, wantPaid: true, wantLookups: 1}, + {name: "a failed voting tier lookup", data: v2Body, client: paid, lookupErr: errors.New("premium unavailable"), flagOn: true, wantStatus: http.StatusInternalServerError, wantLookups: 1}, + {name: "a failed paid lookup", data: v2Body, stored: v2Message, client: paid, lookupErr: errors.New("premium unavailable"), wantStatus: http.StatusInternalServerError, wantLookups: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &countingPremiumClient{MockLookupClient: tt.client, err: tt.lookupErr} + stubPremiumClient(t, client) + + utils.FeatureFlags = flagOff + if tt.flagOn { + utils.FeatureFlags = flagOn + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + hasPaid, ok := componentsV2Gate(c, userId, guildId, &botcontext.BotContext{}, tt.data.entersComponentsV2(tt.stored), tt.data.usesComponentsV2(), tt.votingTier, tt.paidTier, "Failed to save") + require.Equal(t, tt.wantLookups, client.lookups) + + if tt.wantStatus == 0 { + require.True(t, ok) + require.Equal(t, tt.wantPaid, hasPaid) + require.False(t, c.Writer.Written()) + return + } + + require.False(t, ok) + require.Equal(t, tt.wantStatus, w.Code) + + if tt.wantStatus == http.StatusInternalServerError { + require.Len(t, c.Errors, 1) + require.Empty(t, w.Body.String()) + + var apiError *app.ApiError + require.ErrorAs(t, c.Errors.Last(), &apiError) + require.Equal(t, "Failed to save", apiError.ExternalMessage) + return + } + + want := componentsV2UnavailableMessage + if tt.wantStatus == http.StatusPaymentRequired { + want = componentsV2PremiumMessage + } + + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, want, body["error"]) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/supporthours.go b/backend/app/http/endpoints/api/panel/supporthours.go index 7b485df6..4fb21c87 100644 --- a/backend/app/http/endpoints/api/panel/supporthours.go +++ b/backend/app/http/endpoints/api/panel/supporthours.go @@ -1,7 +1,6 @@ package api import ( - "fmt" "net/http" "strconv" "time" @@ -48,7 +47,7 @@ func GetSupportHours(c *gin.Context) { panelIdStr := c.Param("panelid") panelId, err := strconv.Atoi(panelIdStr) if err != nil { - c.JSON(http.StatusBadRequest, utils.ErrorStr(fmt.Sprintf("Invalid panel ID provided: %s", c.Param("panelId")))) + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid panel ID provided: %s", panelIdStr)) return } @@ -60,7 +59,7 @@ func GetSupportHours(c *gin.Context) { } if panel.GuildId != guildId { - c.JSON(http.StatusNotFound, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + c.JSON(http.StatusNotFound, utils.ErrorStr("Panel not found: %d", panelId)) return } @@ -158,7 +157,7 @@ func SetSupportHours(c *gin.Context) { panelIdStr := c.Param("panelid") panelId, err := strconv.Atoi(panelIdStr) if err != nil { - c.JSON(http.StatusBadRequest, utils.ErrorStr(fmt.Sprintf("Invalid panel ID provided: %s", c.Param("panelId")))) + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid panel ID provided: %s", panelIdStr)) return } @@ -178,7 +177,7 @@ func SetSupportHours(c *gin.Context) { // For free users, check if they already have support hours on another panel if premiumTier == premium.None { // Get all panels with support hours for this guild - allPanels, err := dbclient.Client.Panel.GetByGuild(c, guildId) + allPanels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(c, guildId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to process request")) return @@ -215,7 +214,7 @@ func SetSupportHours(c *gin.Context) { } if panel.GuildId != guildId { - c.JSON(http.StatusNotFound, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + c.JSON(http.StatusNotFound, utils.ErrorStr("Panel not found: %d", panelId)) return } @@ -227,7 +226,7 @@ func SetSupportHours(c *gin.Context) { // Validate timezone if !database.IsValidTimezone(requestBody.Timezone) { - c.JSON(http.StatusBadRequest, utils.ErrorStr(fmt.Sprintf("Invalid timezone: %s", requestBody.Timezone))) + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid timezone: %s", requestBody.Timezone)) return } @@ -380,7 +379,7 @@ func DeleteSupportHours(c *gin.Context) { panelIdStr := c.Param("panelid") panelId, err := strconv.Atoi(panelIdStr) if err != nil { - c.JSON(http.StatusBadRequest, utils.ErrorStr(fmt.Sprintf("Invalid panel ID provided: %s", c.Param("panelId")))) + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid panel ID provided: %s", panelIdStr)) return } @@ -392,7 +391,7 @@ func DeleteSupportHours(c *gin.Context) { } if panel.GuildId != guildId { - c.JSON(http.StatusNotFound, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + c.JSON(http.StatusNotFound, utils.ErrorStr("Panel not found: %d", panelId)) return } @@ -442,7 +441,7 @@ func IsPanelActive(c *gin.Context) { panelIdStr := c.Param("panelid") panelId, err := strconv.Atoi(panelIdStr) if err != nil { - c.JSON(http.StatusBadRequest, utils.ErrorStr(fmt.Sprintf("Invalid panel ID provided: %s", c.Param("panelId")))) + c.JSON(http.StatusBadRequest, utils.ErrorStr("Invalid panel ID provided: %s", panelIdStr)) return } @@ -454,7 +453,7 @@ func IsPanelActive(c *gin.Context) { } if panel.GuildId != guildId { - c.JSON(http.StatusNotFound, utils.ErrorStr(fmt.Sprintf("Panel not found: %d", panelId))) + c.JSON(http.StatusNotFound, utils.ErrorStr("Panel not found: %d", panelId)) return } diff --git a/backend/app/http/endpoints/api/panel/validation.go b/backend/app/http/endpoints/api/panel/validation.go index 9a2ef810..4cd94400 100644 --- a/backend/app/http/endpoints/api/panel/validation.go +++ b/backend/app/http/endpoints/api/panel/validation.go @@ -12,7 +12,6 @@ import ( "github.com/TicketsBot-cloud/database" "github.com/TicketsBot-cloud/gdl/objects/channel" "github.com/TicketsBot-cloud/gdl/objects/guild" - "github.com/TicketsBot-cloud/gdl/objects/interaction/component" "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation" "github.com/ticketsbot-cloud/dashboard/backend/app/http/validation/defaults" @@ -62,13 +61,18 @@ const ( MentionBehaviourDelete = "delete" ) +const multiPanelNameMaxLength = 100 + +const panelNameMaxLength = 100 + type PanelValidationContext struct { - Data panelBody - GuildId uint64 - IsPremium bool - BotContext *botcontext.BotContext - Channels []channel.Channel - Roles []guild.Role + Data panelBody + GuildId uint64 + IsPremium bool + HasPaidPremium bool + BotContext *botcontext.BotContext + Channels []channel.Channel + Roles []guild.Role } func ValidatePanelBody(validationContext PanelValidationContext) error { @@ -80,6 +84,7 @@ func ValidatePanelBody(validationContext PanelValidationContext) error { func panelValidators() []validation.Validator[PanelValidationContext] { return []validation.Validator[PanelValidationContext]{ + validateName, validateTitle, validateContent, validateChannelId, @@ -107,6 +112,57 @@ func panelValidators() []validation.Validator[PanelValidationContext] { validateSupportCanType, validateAutoClose, validateMentionBehaviour, + validateMessageComponents, + validateWelcomeMessageComponents, + } +} + +func normaliseMultiPanelName(raw string) (*string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return nil, nil + } + + if utf8.RuneCountInString(name) > multiPanelNameMaxLength { + return nil, validation.NewInvalidInputErrorf("Multi-panel name must be %d characters or fewer", multiPanelNameMaxLength) + } + + if strings.ContainsRune(name, 0) { + return nil, validation.NewInvalidInputError("Multi-panel name cannot contain null characters") + } + + return &name, nil +} + +func (p panelBody) trimmedName() *string { + if p.Name == nil { + return nil + } + + name := strings.TrimSpace(*p.Name) + if name == "" { + return nil + } + + return &name +} + +func validateName(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + name := ctx.Data.trimmedName() + if name == nil { + return nil + } + + if utf8.RuneCountInString(*name) > panelNameMaxLength { + return validation.NewInvalidInputErrorf("Panel name must be %d characters or fewer", panelNameMaxLength) + } + + if strings.ContainsRune(*name, 0) { + return validation.NewInvalidInputError("Panel name cannot contain null characters") + } + + return nil } } @@ -229,9 +285,13 @@ func validateEmoji(c PanelValidationContext) validation.ValidationFunc { var urlRegex = regexp.MustCompile(`^https?://([-a-zA-Z0-9@:%._+~#=]{1,256})\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$`) +func isValidClassicUrl(url string) bool { + return len(url) <= 255 && urlRegex.MatchString(url) +} + func validateNullableUrl(url *string) validation.ValidationFunc { return func() error { - if url != nil && (len(*url) > 255 || !urlRegex.MatchString(*url)) { + if url != nil && !isValidClassicUrl(*url) { return validation.NewInvalidInputError("Invalid URL") } @@ -240,16 +300,26 @@ func validateNullableUrl(url *string) validation.ValidationFunc { } func validateImageUrl(ctx PanelValidationContext) validation.ValidationFunc { + if ctx.Data.MessageUsesComponentsV2 { + return skipValidation + } + return validateNullableUrl(ctx.Data.ImageUrl) } func validateThumbnailUrl(ctx PanelValidationContext) validation.ValidationFunc { + if ctx.Data.MessageUsesComponentsV2 { + return skipValidation + } + return validateNullableUrl(ctx.Data.ThumbnailUrl) } +func skipValidation() error { return nil } + func validateButtonStyle(ctx PanelValidationContext) validation.ValidationFunc { return func() error { - if ctx.Data.ButtonStyle < component.ButtonStylePrimary && ctx.Data.ButtonStyle > component.ButtonStyleDanger { + if !isPanelButtonStyle(ctx.Data.ButtonStyle) { return validation.NewInvalidInputError("Invalid button style") } @@ -450,6 +520,10 @@ func validateNamingScheme(ctx PanelValidationContext) validation.ValidationFunc } func validateWelcomeMessage(ctx PanelValidationContext) validation.ValidationFunc { + if ctx.Data.WelcomeMessageUsesComponentsV2 { + return skipValidation + } + return func() error { return validateEmbed(ctx.Data.WelcomeMessage) } @@ -492,6 +566,10 @@ func validateAccessControlList(ctx PanelValidationContext) validation.Validation } } +func embedHasContent(e *types.CustomEmbed) bool { + return e.Title != nil || e.Description != nil || len(e.Fields) > 0 || e.ImageUrl != nil || e.ThumbnailUrl != nil +} + func validateCloseReasons(ctx PanelValidationContext) validation.ValidationFunc { return func() error { if ctx.Data.CloseReasons == nil { @@ -509,8 +587,8 @@ func validateCloseReasons(ctx PanelValidationContext) validation.ValidationFunc return validation.NewInvalidInputError("Close reasons must be 100 characters or fewer") } - if len(reason) > 255 { - return validation.NewInvalidInputErrorf("Close reason \"%s\" is too long", reason) + if strings.ContainsRune(reason, 0) { + return validation.NewInvalidInputError("Close reasons cannot contain null characters") } for _, previous := range reasons[:i] { @@ -529,10 +607,57 @@ func validateEmbed(e *types.CustomEmbed) error { return nil } - if e.Title == nil && e.Description == nil && len(e.Fields) == 0 && e.ImageUrl == nil && e.ThumbnailUrl == nil { + if !embedHasContent(e) { return validation.NewInvalidInputError("Your embed message does not contain any content") } + return validateEmbedContent(e) +} + +func classicEmbedForV2(e *types.CustomEmbed) *types.CustomEmbed { + if e == nil || !embedHasContent(e) || validateEmbedContent(e) != nil || validate.Struct(e) != nil { + return nil + } + + return e +} + +func (p *panelBody) dropHiddenClassicEmbed() { + if p.WelcomeMessageUsesComponentsV2 { + p.WelcomeMessage = classicEmbedForV2(p.WelcomeMessage) + } +} + +func (p *panelBody) retainClassicFields(existing database.Panel, colourSent bool) { + if !p.MessageUsesComponentsV2 { + return + } + + if strings.TrimSpace(p.Content) == "" || utf8.RuneCountInString(p.Content) > 4096 { + p.Content = existing.Content + } + + if !colourSent || (p.Colour != nil && *p.Colour > maxAccentColour) { + p.Colour = utils.ConvertPtr[uint32](existing.Colour) + } + + p.ImageUrl = suppliedClassicUrlOr(p.ImageUrl, existing.ImageUrl) + p.ThumbnailUrl = suppliedClassicUrlOr(p.ThumbnailUrl, existing.ThumbnailUrl) +} + +func suppliedClassicUrlOr(supplied, existing *string) *string { + if supplied != nil && isValidClassicUrl(*supplied) { + return supplied + } + + return existing +} + +func validClassicUrlOrNil(url *string) *string { + return suppliedClassicUrlOr(url, nil) +} + +func validateEmbedContent(e *types.CustomEmbed) error { if err := e.ValidateUrls(); err != nil { return validation.NewInvalidInputError(err.Error()) } @@ -684,6 +809,64 @@ func validateMentionBehaviour(ctx PanelValidationContext) validation.ValidationF } } +func validateMessageComponents(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if !ctx.Data.MessageUsesComponentsV2 { + return nil + } + + if !ctx.HasPaidPremium { + return validation.NewInvalidInputError(componentsV2PremiumMessage) + } + + result, err := validateComponentTree(ctx.Data.MessageComponents, treeRules{ownPanel: true}) + if err != nil || result.ownPlaced { + return err + } + + return checkComponentBudget(result.count, panelMessageReserved) + } +} + +func validateWelcomeMessageComponents(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if !ctx.Data.WelcomeMessageUsesComponentsV2 { + return nil + } + + if !ctx.HasPaidPremium { + return validation.NewInvalidInputError(componentsV2PremiumMessage) + } + + if len(ctx.Data.WelcomeMessageComponents) == 0 { + return validation.NewInvalidInputError("The welcome message needs at least one component") + } + + _, err := validateComponentTree(ctx.Data.WelcomeMessageComponents, treeRules{reserved: welcomeMessageReserved(ctx.Data), allowAvatarPlaceholder: true}) + return err + } +} + +func welcomeMessageReserved(data panelBody) int { + visible := 0 + if !data.HideCloseButton { + visible++ + } + if !data.HideCloseWithReasonButton { + visible++ + } + if !data.HideClaimButton && !data.UseThreads { + visible++ + } + + reserved := welcomeFormAnswersReserved + if visible > 0 { + reserved += visible + 1 + } + + return reserved +} + func validateAutoClose(ctx PanelValidationContext) validation.ValidationFunc { return func() error { ac := ctx.Data.AutoClose diff --git a/backend/app/http/endpoints/api/panel/validation_test.go b/backend/app/http/endpoints/api/panel/validation_test.go new file mode 100644 index 00000000..cf8eafb8 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/validation_test.go @@ -0,0 +1,570 @@ +package api + +import ( + "fmt" + "strings" + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/stretchr/testify/require" + "github.com/ticketsbot-cloud/dashboard/backend/utils" + "github.com/ticketsbot-cloud/dashboard/backend/utils/types" +) + +func TestValidateMessageComponents(t *testing.T) { + tests := []struct { + name string + data panelBody + paid bool + wantErr string + }{ + {name: "classic mode skips the tree and the premium check", data: panelBody{MessageComponents: texts(60)}}, + {name: "v2 without paid premium", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: texts(1)}, wantErr: "premium"}, + {name: "v2 with paid premium", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: texts(1)}, paid: true}, + {name: "empty tree is allowed for the panel message", data: panelBody{MessageUsesComponentsV2: true}, paid: true}, + {name: "38 components leave room for the open ticket row", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: texts(38)}, paid: true}, + {name: "39 components do not", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: texts(39)}, paid: true, wantErr: "Too many components"}, + {name: "a placed panel button needs no reserved row", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: append(texts(38), row(ownPanelBtn()))}, paid: true}, + {name: "a placed panel button still counts", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: append(texts(39), row(ownPanelBtn()))}, paid: true, wantErr: "Too many components"}, + {name: "panel placement is not available", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: []component.Component{row(panelBtn(1))}}, paid: true, wantErr: "Panel button"}, + {name: "avatar placeholder is rejected as a gallery url", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: []component.Component{gallery(types.AvatarUrlPlaceholder)}}, paid: true, wantErr: "Media gallery"}, + {name: "avatar placeholder is rejected as a thumbnail url", data: panelBody{MessageUsesComponentsV2: true, MessageComponents: []component.Component{section(thumb(types.AvatarUrlPlaceholder), text("a"))}}, paid: true, wantErr: "Thumbnail"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMessageComponents(PanelValidationContext{Data: tt.data, HasPaidPremium: tt.paid})() + + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestValidateMessageComponents_VotingPremiumIsNotEnough(t *testing.T) { + err := validateMessageComponents(PanelValidationContext{ + Data: panelBody{MessageUsesComponentsV2: true, MessageComponents: texts(1)}, + IsPremium: true, + })() + require.ErrorContains(t, err, "premium") +} + +func TestValidateCloseReasons(t *testing.T) { + reasons := func(list ...string) *database.PanelCloseReasons { + return &database.PanelCloseReasons{Reasons: list, AllowCustom: true} + } + + many := make([]string, 26) + for i := range many { + many[i] = fmt.Sprintf("reason %d", i) + } + + tests := []struct { + name string + data panelBody + wantErr string + }{ + {name: "absent", data: panelBody{}}, + {name: "presets", data: panelBody{CloseReasons: reasons("Resolved", "Duplicate")}}, + {name: "100 emoji are 100 characters", data: panelBody{CloseReasons: reasons(strings.Repeat("😀", 100))}}, + {name: "101 characters", data: panelBody{CloseReasons: reasons(strings.Repeat("a", 101))}, wantErr: "100 characters"}, + {name: "null character", data: panelBody{CloseReasons: reasons("a\x00b")}, wantErr: "null characters"}, + {name: "duplicates ignore case", data: panelBody{CloseReasons: reasons("Resolved", "resolved")}, wantErr: "Duplicate"}, + {name: "26 presets", data: panelBody{CloseReasons: reasons(many...)}, wantErr: "25 close reasons"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCloseReasons(PanelValidationContext{Data: tt.data})() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestValidateWelcomeMessageComponents(t *testing.T) { + tests := []struct { + name string + data panelBody + paid bool + wantErr string + }{ + {name: "classic mode skips everything", data: panelBody{}}, + {name: "classic without premium ignores the tree", data: panelBody{WelcomeMessageComponents: append(texts(60), gallery("not a url"))}}, + {name: "without paid premium", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(1)}, wantErr: "premium"}, + {name: "empty tree", data: panelBody{WelcomeMessageUsesComponentsV2: true}, paid: true, wantErr: "at least one component"}, + {name: "empty non-nil tree", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{}}, paid: true, wantErr: "at least one component"}, + {name: "30 components with all buttons visible", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(30)}, paid: true}, + {name: "31 components with all buttons visible", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(31)}, paid: true, wantErr: "Too many components"}, + {name: "34 components with every button hidden", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(34), HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, paid: true}, + {name: "35 components with every button hidden", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(35), HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, paid: true, wantErr: "Too many components"}, + {name: "claim is not shown in thread mode", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(31), UseThreads: true}, paid: true}, + {name: "thread mode with one more", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(32), UseThreads: true}, paid: true, wantErr: "Too many components"}, + {name: "avatar placeholder as a gallery url", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{gallery(types.AvatarUrlPlaceholder)}}, paid: true}, + {name: "avatar placeholder as a thumbnail url", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{section(thumb(types.AvatarUrlPlaceholder), text("a"))}}, paid: true}, + {name: "avatar placeholder as a link button url", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{row(linkBtn("Go", types.AvatarUrlPlaceholder))}}, paid: true, wantErr: "Link button"}, + {name: "avatar placeholder embedded in a longer url", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{gallery("https://example.com/" + types.AvatarUrlPlaceholder)}}, paid: true, wantErr: "Media gallery"}, + {name: "avatar placeholder with surrounding spaces", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{gallery(" " + types.AvatarUrlPlaceholder)}}, paid: true, wantErr: "Media gallery"}, + {name: "avatar placeholder in upper case", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: []component.Component{gallery("%AVATAR_URL%")}}, paid: true, wantErr: "Media gallery"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateWelcomeMessageComponents(PanelValidationContext{Data: tt.data, HasPaidPremium: tt.paid})() + + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestWelcomeMessageReserved(t *testing.T) { + tests := []struct { + name string + data panelBody + want int + }{ + {"all buttons", panelBody{}, 10}, + {"close only", panelBody{HideCloseWithReasonButton: true, HideClaimButton: true}, 8}, + {"none", panelBody{HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, 6}, + {"thread mode drops claim", panelBody{UseThreads: true}, 9}, + {"thread mode with only claim visible", panelBody{UseThreads: true, HideCloseButton: true, HideCloseWithReasonButton: true}, 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, welcomeMessageReserved(tt.data)) + }) + } +} + +func TestClassicValidatorsAreSkippedInV2(t *testing.T) { + badUrl := strPtr("not a url") + emptyEmbed := &types.CustomEmbed{} + + t.Run("image and thumbnail", func(t *testing.T) { + classic := PanelValidationContext{Data: panelBody{ImageUrl: badUrl, ThumbnailUrl: badUrl}} + require.Error(t, validateImageUrl(classic)()) + require.Error(t, validateThumbnailUrl(classic)()) + + v2 := PanelValidationContext{Data: panelBody{ImageUrl: badUrl, ThumbnailUrl: badUrl, MessageUsesComponentsV2: true}} + require.NoError(t, validateImageUrl(v2)()) + require.NoError(t, validateThumbnailUrl(v2)()) + }) + + t.Run("welcome embed", func(t *testing.T) { + classic := PanelValidationContext{Data: panelBody{WelcomeMessage: emptyEmbed}} + require.ErrorContains(t, validateWelcomeMessage(classic)(), "does not contain any content") + + v2 := PanelValidationContext{Data: panelBody{WelcomeMessage: emptyEmbed, WelcomeMessageUsesComponentsV2: true}} + require.NoError(t, validateWelcomeMessage(v2)()) + }) + + t.Run("welcome embed check follows the welcome flag", func(t *testing.T) { + ctx := PanelValidationContext{Data: panelBody{WelcomeMessage: emptyEmbed, MessageUsesComponentsV2: true}} + require.Error(t, validateWelcomeMessage(ctx)()) + }) +} + +func TestClassicEmbedForV2(t *testing.T) { + title := strPtr("Hello") + badUrl := strPtr("javascript:alert(1)") + + tests := []struct { + name string + in *types.CustomEmbed + keep bool + }{ + {"nil", nil, false}, + {"empty embed", &types.CustomEmbed{}, false}, + {"embed with a title", &types.CustomEmbed{Title: title}, true}, + {"embed with an invalid image url", &types.CustomEmbed{Title: title, ImageUrl: badUrl}, false}, + {"embed over the character limit", &types.CustomEmbed{Description: strPtr(strings.Repeat("a", 4096)), Title: strPtr(strings.Repeat("b", 2000))}, false}, + {"embed with fields only", &types.CustomEmbed{Fields: []types.Field{{Name: "a", Value: "b"}}}, true}, + {"embed title at 256 characters", &types.CustomEmbed{Title: strPtr(strings.Repeat("a", 256))}, true}, + {"embed title over 256 characters", &types.CustomEmbed{Title: strPtr(strings.Repeat("a", 257))}, false}, + {"embed field with an empty name", &types.CustomEmbed{Fields: []types.Field{{Name: "", Value: "b"}}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classicEmbedForV2(tt.in) + if tt.keep { + require.Same(t, tt.in, got) + } else { + require.Nil(t, got) + } + }) + } +} + +func TestNormaliseMultiPanelName(t *testing.T) { + tests := []struct { + name string + in string + want *string + wantErr bool + }{ + {"blank", "", nil, false}, + {"whitespace only", " \t\n", nil, false}, + {"trimmed", " Support ", strPtr("Support"), false}, + {"100 characters", strings.Repeat("a", 100), strPtr(strings.Repeat("a", 100)), false}, + {"101 characters", strings.Repeat("a", 101), nil, true}, + {"100 characters plus padding", " " + strings.Repeat("a", 100) + " ", strPtr(strings.Repeat("a", 100)), false}, + {"100 multibyte characters", strings.Repeat("é", 100), strPtr(strings.Repeat("é", 100)), false}, + {"101 multibyte characters", strings.Repeat("é", 101), nil, true}, + {"null byte", "Ma\x00in", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normaliseMultiPanelName(tt.in) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func panelConfigs(ids ...int) []panelConfiguration { + configs := make([]panelConfiguration, len(ids)) + for i, id := range ids { + configs[i] = panelConfiguration{PanelId: id} + } + return configs +} + +func TestValidateMultiPanelComponents(t *testing.T) { + threePanels := panelConfigs(1, 2, 3) + + tests := []struct { + name string + data multiPanelCreateData + wantErr string + }{ + {name: "classic mode skips the tree", data: multiPanelCreateData{Panels: threePanels, Components: texts(60)}}, + + {name: "button mode, 3 unplaced reserves 4", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: texts(36)}}, + {name: "button mode, 3 unplaced and one over", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: texts(37)}, wantErr: "Too many components"}, + {name: "button mode, placing a panel keeps its button", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: append(texts(33), row(panelBtn(1)))}}, + {name: "button mode, placing a panel uses the full budget", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: append(texts(35), row(panelBtn(1)))}}, + {name: "button mode, placing a panel one over", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: append(texts(36), row(panelBtn(1)))}, wantErr: "Too many components"}, + {name: "button mode, everything placed reserves nothing", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: append(texts(36), row(panelBtn(1), panelBtn(2), panelBtn(3)))}}, + {name: "button mode, everything placed one over", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: append(texts(37), row(panelBtn(1), panelBtn(2), panelBtn(3)))}, wantErr: "Too many components"}, + {name: "button mode, 6 unplaced reserves 2 rows and 6 buttons", data: multiPanelCreateData{UsesComponentsV2: true, Panels: panelConfigs(1, 2, 3, 4, 5, 6), Components: texts(32)}}, + {name: "button mode, 6 unplaced and one over", data: multiPanelCreateData{UsesComponentsV2: true, Panels: panelConfigs(1, 2, 3, 4, 5, 6), Components: texts(33)}, wantErr: "Too many components"}, + {name: "button mode, 5 unplaced reserves 1 row", data: multiPanelCreateData{UsesComponentsV2: true, Panels: panelConfigs(1, 2, 3, 4, 5), Components: texts(34)}}, + + {name: "dropdown mode reserves 2", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: texts(38)}}, + {name: "dropdown mode reserves 2, one over", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: texts(39)}, wantErr: "Too many components"}, + {name: "placed dropdown reserves nothing extra", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: append(texts(38), row(dropdownNode()))}}, + {name: "placed dropdown over the limit", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: append(texts(39), row(dropdownNode()))}, wantErr: "Too many components"}, + {name: "dropdown mode with every panel placed reserves nothing", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: append(texts(36), row(panelBtn(1), panelBtn(2), panelBtn(3)))}}, + {name: "placed dropdown with every panel placed as a button", data: multiPanelCreateData{UsesComponentsV2: true, SelectMenu: true, Panels: threePanels, Components: []component.Component{row(panelBtn(1), panelBtn(2), panelBtn(3)), row(dropdownNode())}}, wantErr: "already placed as a button"}, + {name: "placed dropdown in button mode", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: []component.Component{row(dropdownNode())}}, wantErr: "Dropdown"}, + + {name: "empty tree", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels}}, + {name: "placement of a panel outside the list", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: []component.Component{row(panelBtn(9))}}, wantErr: "not part of this multi-panel"}, + {name: "avatar placeholder is rejected as a gallery url", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: []component.Component{gallery(types.AvatarUrlPlaceholder)}}, wantErr: "Media gallery"}, + {name: "avatar placeholder is rejected as a thumbnail url", data: multiPanelCreateData{UsesComponentsV2: true, Panels: threePanels, Components: []component.Component{section(thumb(types.AvatarUrlPlaceholder), text("a"))}}, wantErr: "Thumbnail"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMultiPanelComponents(tt.data) + + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestMultiPanelReserved(t *testing.T) { + tests := []struct { + name string + dropdownMode bool + dropdownPlaced bool + unplaced int + want int + }{ + {"button mode, none", false, false, 0, 0}, + {"button mode, 1", false, false, 1, 2}, + {"button mode, 5", false, false, 5, 6}, + {"button mode, 6", false, false, 6, 8}, + {"button mode, 15", false, false, 15, 18}, + {"dropdown mode, none", true, false, 0, 0}, + {"dropdown mode, some", true, false, 4, 2}, + {"dropdown mode, placed", true, true, 4, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, multiPanelReserved(tt.dropdownMode, tt.dropdownPlaced, tt.unplaced)) + }) + } +} + +func TestStripUnknownPlacements(t *testing.T) { + tests := []struct { + name string + panels []panelConfiguration + in []component.Component + want int + }{ + {"nothing to strip", panelConfigs(1, 2), []component.Component{text("a"), row(panelBtn(1), panelBtn(2))}, 2}, + {"unknown button is removed from a mixed row", panelConfigs(1), []component.Component{row(panelBtn(1), panelBtn(2))}, 1}, + {"a row left empty is removed", panelConfigs(1), []component.Component{text("a"), row(panelBtn(2))}, 1}, + {"a container left empty is removed", panelConfigs(1), []component.Component{container(row(panelBtn(2)))}, 0}, + {"a section whose accessory is removed goes with it", panelConfigs(1), []component.Component{section(panelBtn(2), text("a"))}, 0}, + {"a section with a known accessory stays", panelConfigs(2), []component.Component{section(panelBtn(2), text("a"))}, 1}, + {"link buttons and the dropdown are untouched", panelConfigs(1), []component.Component{row(linkBtn("Go", "https://example.com")), row(dropdownNode())}, 2}, + {"malformed marker left for validation", panelConfigs(1), []component.Component{row(component.BuildButton(component.Button{CustomId: "panel:abc"}))}, 1}, + {"empty tree", panelConfigs(1), nil, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := multiPanelCreateData{Panels: tt.panels, Components: tt.in} + d.stripUnknownPlacements() + require.Len(t, d.Components, tt.want) + }) + } +} + +func TestStripUnknownPlacements_ThenValidates(t *testing.T) { + d := multiPanelCreateData{ + UsesComponentsV2: true, + Panels: panelConfigs(1, 2), + Components: []component.Component{text("a"), row(panelBtn(1), panelBtn(7))}, + } + + _, err := validateComponentTree(d.Components, treeRules{validPanelIds: map[int]bool{1: true, 2: true}}) + require.Error(t, err, "the stale placement fails validation until it is stripped") + + d.stripUnknownPlacements() + require.NoError(t, validateMultiPanelComponents(d)) +} + +func TestValidateDropdownLabels(t *testing.T) { + panels := []database.Panel{ + {PanelId: 10, ButtonLabel: "", Title: "Ten"}, + {PanelId: 20, ButtonLabel: "Twenty", Title: "Twenty"}, + } + + t.Run("config is matched by panel id, not slice position", func(t *testing.T) { + configs := []panelConfiguration{ + {PanelId: 20}, + {PanelId: 10, CustomLabel: strPtr("Custom ten")}, + } + + require.NoError(t, validateDropdownLabels(panels, configs)) + }) + + t.Run("a panel with no label anywhere is rejected", func(t *testing.T) { + configs := []panelConfiguration{{PanelId: 10}, {PanelId: 20}} + + require.ErrorContains(t, validateDropdownLabels(panels, configs), "Ten") + }) + + t.Run("empty custom label falls back", func(t *testing.T) { + configs := []panelConfiguration{{PanelId: 10, CustomLabel: strPtr("")}, {PanelId: 20, CustomLabel: strPtr("")}} + + require.Error(t, validateDropdownLabels(panels, configs)) + }) +} + +func TestDropHiddenClassicEmbed(t *testing.T) { + invalid := func() *types.CustomEmbed { return &types.CustomEmbed{Title: strPtr(strings.Repeat("a", 257))} } + valid := func() *types.CustomEmbed { return &types.CustomEmbed{Title: strPtr("ok")} } + + tests := []struct { + name string + body panelBody + wantKept bool + wantErr bool + }{ + {name: "v2 welcome with an invalid hidden embed is dropped", body: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessage: invalid()}}, + {name: "v2 welcome with a valid hidden embed is kept", body: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessage: valid()}, wantKept: true}, + {name: "v2 welcome with no embed", body: panelBody{WelcomeMessageUsesComponentsV2: true}}, + {name: "classic welcome with an invalid embed", body: panelBody{WelcomeMessage: invalid()}, wantKept: true, wantErr: true}, + {name: "message flag alone keeps the welcome embed", body: panelBody{MessageUsesComponentsV2: true, WelcomeMessage: invalid()}, wantKept: true, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.body.dropHiddenClassicEmbed() + require.Equal(t, tt.wantKept, tt.body.WelcomeMessage != nil) + + err := validate.Struct(tt.body) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateButtonStyle(t *testing.T) { + tests := []struct { + style component.ButtonStyle + wantErr bool + }{ + {0, true}, + {component.ButtonStylePrimary, false}, + {component.ButtonStyleSecondary, false}, + {component.ButtonStyleSuccess, false}, + {component.ButtonStyleDanger, false}, + {component.ButtonStyleLink, true}, + {component.ButtonStylePremium, true}, + {7, true}, + } + + for _, tt := range tests { + t.Run(fmt.Sprint(tt.style), func(t *testing.T) { + err := validateButtonStyle(PanelValidationContext{Data: panelBody{ButtonStyle: tt.style}})() + if tt.wantErr { + require.ErrorContains(t, err, "Invalid button style") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRetainClassicFields(t *testing.T) { + stored := database.Panel{ + Title: "Stored title", + Content: "Stored content", + Colour: utils.Ptr[int32](0x112233), + ImageUrl: strPtr("https://example.com/stored.png"), + ThumbnailUrl: strPtr("https://example.com/stored-thumb.png"), + } + storedColour := utils.Ptr[uint32](0x112233) + goodUrl := strPtr("https://example.com/new.png") + + tests := []struct { + name string + in panelBody + colourSent bool + want panelBody + }{ + { + name: "classic mode is untouched", + in: panelBody{Title: "", Content: "", Colour: nil}, + want: panelBody{Title: "", Content: "", Colour: nil}, + }, + { + name: "v2 request omitting everything", + in: panelBody{MessageUsesComponentsV2: true}, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "v2 request with valid values replaces them", + in: panelBody{MessageUsesComponentsV2: true, Title: "New", Content: "New content", Colour: utils.Ptr[uint32](0xFFFFFF), ImageUrl: goodUrl, ThumbnailUrl: goodUrl}, + colourSent: true, + want: panelBody{MessageUsesComponentsV2: true, Title: "New", Content: "New content", Colour: utils.Ptr[uint32](0xFFFFFF), ImageUrl: goodUrl, ThumbnailUrl: goodUrl}, + }, + { + name: "black replaces the stored colour", + in: panelBody{MessageUsesComponentsV2: true, Title: "New", Colour: utils.Ptr[uint32](0)}, + colourSent: true, + want: panelBody{MessageUsesComponentsV2: true, Title: "New", Content: "Stored content", Colour: utils.Ptr[uint32](0), ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "an explicit null clears the stored colour", + in: panelBody{MessageUsesComponentsV2: true}, + colourSent: true, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "content at 4096 characters is accepted", + in: panelBody{MessageUsesComponentsV2: true, Content: strings.Repeat("a", 4096)}, + want: panelBody{MessageUsesComponentsV2: true, Content: strings.Repeat("a", 4096), Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "content at 4097 characters keeps the stored one", + in: panelBody{MessageUsesComponentsV2: true, Content: strings.Repeat("a", 4097)}, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "colour above 0xFFFFFF keeps the stored one", + in: panelBody{MessageUsesComponentsV2: true, Colour: utils.Ptr[uint32](0x1000000)}, + colourSent: true, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "invalid urls never replace the stored ones", + in: panelBody{MessageUsesComponentsV2: true, ImageUrl: strPtr("javascript:alert(1)"), ThumbnailUrl: strPtr("not a url")}, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + { + name: "url over 255 characters keeps the stored one", + in: panelBody{MessageUsesComponentsV2: true, ImageUrl: strPtr("https://example.com/" + strings.Repeat("a", 236))}, + want: panelBody{MessageUsesComponentsV2: true, Content: "Stored content", Colour: storedColour, ImageUrl: stored.ImageUrl, ThumbnailUrl: stored.ThumbnailUrl}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.in.retainClassicFields(stored, tt.colourSent) + require.Equal(t, tt.want, tt.in) + }) + } + + t.Run("nothing stored or supplied leaves urls nil", func(t *testing.T) { + body := panelBody{MessageUsesComponentsV2: true, ImageUrl: strPtr("nope")} + body.retainClassicFields(database.Panel{Title: "t", Content: "c"}, false) + require.Nil(t, body.ImageUrl) + require.Nil(t, body.ThumbnailUrl) + }) + + t.Run("nothing stored or supplied leaves colour nil", func(t *testing.T) { + body := panelBody{MessageUsesComponentsV2: true} + body.retainClassicFields(database.Panel{Title: "t", Content: "c"}, false) + require.Nil(t, body.Colour) + }) +} + +func TestValidClassicUrlOrNil(t *testing.T) { + tests := []struct { + name string + in *string + want bool + }{ + {"nil", nil, false}, + {"valid", strPtr("https://example.com/a.png"), true}, + {"javascript scheme", strPtr("javascript:alert(1)"), false}, + {"no scheme", strPtr("example.com/a.png"), false}, + {"255 characters", strPtr("https://example.com/" + strings.Repeat("a", 235)), true}, + {"256 characters", strPtr("https://example.com/" + strings.Repeat("a", 236)), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validClassicUrlOrNil(tt.in) + if tt.want { + require.Same(t, tt.in, got) + } else { + require.Nil(t, got) + } + }) + } +} diff --git a/backend/app/http/endpoints/api/ticket/bulkcloserequest.go b/backend/app/http/endpoints/api/ticket/bulkcloserequest.go index 32d8568d..634772da 100644 --- a/backend/app/http/endpoints/api/ticket/bulkcloserequest.go +++ b/backend/app/http/endpoints/api/ticket/bulkcloserequest.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "time" + "unicode/utf8" "github.com/TicketsBot-cloud/common/model" dbmodel "github.com/TicketsBot-cloud/database" @@ -52,7 +53,7 @@ func BulkCloseRequest(c *gin.Context) { return } - if body.Reason != nil && len(*body.Reason) > 255 { + if body.Reason != nil && utf8.RuneCountInString(*body.Reason) > 255 { c.JSON(http.StatusBadRequest, utils.ErrorStr("Reason must be 255 characters or fewer")) return } diff --git a/backend/app/http/endpoints/api/ticket/closerequest.go b/backend/app/http/endpoints/api/ticket/closerequest.go index b243d7a8..24fb1cf3 100644 --- a/backend/app/http/endpoints/api/ticket/closerequest.go +++ b/backend/app/http/endpoints/api/ticket/closerequest.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/TicketsBot-cloud/common/model" dbmodel "github.com/TicketsBot-cloud/database" @@ -45,7 +46,7 @@ func CloseRequest(c *gin.Context) { return } - if body.Reason != nil && len(*body.Reason) > 255 { + if body.Reason != nil && utf8.RuneCountInString(*body.Reason) > 255 { c.JSON(http.StatusBadRequest, utils.ErrorStr("Reason must be 255 characters or fewer")) return } diff --git a/backend/app/http/endpoints/api/ticket/getticket.go b/backend/app/http/endpoints/api/ticket/getticket.go index 11f53915..7491b3cb 100644 --- a/backend/app/http/endpoints/api/ticket/getticket.go +++ b/backend/app/http/endpoints/api/ticket/getticket.go @@ -94,7 +94,8 @@ func GetTicket(c *gin.Context) { if ticket.PanelId != nil { panel, err := dbclient.Client.Panel.GetById(c, *ticket.PanelId) if err == nil { - panelTitle = &panel.Title + label := panelLabel(panel) + panelTitle = &label } if reasons, err := dbclient.Client.PanelCloseReasons.Get(c, *ticket.PanelId); err == nil { diff --git a/backend/app/http/endpoints/api/ticket/gettickets.go b/backend/app/http/endpoints/api/ticket/gettickets.go index f58c6f94..cc0c1ca3 100644 --- a/backend/app/http/endpoints/api/ticket/gettickets.go +++ b/backend/app/http/endpoints/api/ticket/gettickets.go @@ -111,7 +111,7 @@ func GetTickets(c *gin.Context) { return } - panels, err := dbclient.Client.Panel.GetByGuild(c, guildId) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(c, guildId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch panels for guild from database")) return @@ -119,7 +119,7 @@ func GetTickets(c *gin.Context) { panelTitles := make(map[int]string) for _, panel := range panels { - panelTitles[panel.PanelId] = panel.Title + panelTitles[panel.PanelId] = panelLabel(panel) } // Get user objects @@ -205,7 +205,7 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke } } - panels, err := dbclient.Client.Panel.GetByGuild(c, guildId) + panels, err := dbclient.Client.Panel.GetByGuildWithoutComponents(c, guildId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch panels for guild from database")) return @@ -213,7 +213,7 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke panelTitles := make(map[int]string) for _, panel := range panels { - panelTitles[panel.PanelId] = panel.Title + panelTitles[panel.PanelId] = panelLabel(panel) } // Get user objects @@ -267,6 +267,14 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke }) } +func panelLabel(panel database.Panel) string { + if panel.Name != nil && *panel.Name != "" { + return *panel.Name + } + + return panel.Title +} + func fetchLabelsForTickets(c *gin.Context, guildId uint64, ticketIds []int) (map[int][]ticketLabelData, error) { if len(ticketIds) == 0 { return make(map[int][]ticketLabelData), nil diff --git a/backend/app/http/endpoints/api/ticket/labelassignments.go b/backend/app/http/endpoints/api/ticket/labelassignments.go index 469016a1..db84a671 100644 --- a/backend/app/http/endpoints/api/ticket/labelassignments.go +++ b/backend/app/http/endpoints/api/ticket/labelassignments.go @@ -101,7 +101,7 @@ func SetTicketLabels(ctx *gin.Context) { return } if panel.PanelId != 0 { - topicMsg = fmt.Sprintf("%s | ", panel.Title) + topicMsg = fmt.Sprintf("%s | ", panelLabel(panel)) } } diff --git a/backend/app/http/endpoints/api/user/featureflags.go b/backend/app/http/endpoints/api/user/featureflags.go index c8e4f438..a23dec25 100644 --- a/backend/app/http/endpoints/api/user/featureflags.go +++ b/backend/app/http/endpoints/api/user/featureflags.go @@ -32,6 +32,7 @@ var browserFlags = []string{ "202608_FEATURE_WHITELABEL", "202608_FEATURE_INTEGRATIONS", "202608_FEATURE_AUTOMATIONS", + "202608_COMPONENTS_V2_BUILDER", } // evalBrowserFlags evaluates the allowlisted flags against the given attributes. diff --git a/backend/app/http/endpoints/api/user/notifications.go b/backend/app/http/endpoints/api/user/notifications.go index 968f3803..3b9c09db 100644 --- a/backend/app/http/endpoints/api/user/notifications.go +++ b/backend/app/http/endpoints/api/user/notifications.go @@ -19,13 +19,13 @@ const ( ) type notificationResponse struct { - Id int64 `json:"id"` - Category string `json:"category"` - Title string `json:"title"` - Body string `json:"body"` + Id int64 `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Body string `json:"body"` Link *string `json:"link"` - Read bool `json:"read"` - CreatedAt string `json:"created_at"` + Read bool `json:"read"` + CreatedAt string `json:"created_at"` } type listNotificationsResponse struct { diff --git a/backend/app/http/endpoints/api/user/settings.go b/backend/app/http/endpoints/api/user/settings.go index 0fae838f..40f89194 100644 --- a/backend/app/http/endpoints/api/user/settings.go +++ b/backend/app/http/endpoints/api/user/settings.go @@ -29,8 +29,8 @@ import ( ) const ( - verificationCodeLength = 6 - verificationCodeExpiry = 15 * time.Minute + verificationCodeLength = 6 + verificationCodeExpiry = 15 * time.Minute maxVerificationAttempts = 5 ) @@ -41,10 +41,10 @@ func hmacCode(code string) string { } type settingsResponse struct { - Email *string `json:"email"` - EmailVerified bool `json:"email_verified"` - Preferences []notificationPreferenceResponse `json:"notification_preferences"` - Categories []notify.CategoryInfo `json:"notification_categories"` + Email *string `json:"email"` + EmailVerified bool `json:"email_verified"` + Preferences []notificationPreferenceResponse `json:"notification_preferences"` + Categories []notify.CategoryInfo `json:"notification_categories"` } type notificationPreferenceResponse struct { diff --git a/backend/app/http/middleware/limitjsonbody.go b/backend/app/http/middleware/limitjsonbody.go new file mode 100644 index 00000000..132f677e --- /dev/null +++ b/backend/app/http/middleware/limitjsonbody.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ticketsbot-cloud/dashboard/backend/utils" +) + +var errJSONTooDeep = errors.New("json nesting too deep") + +func LimitJSONBody(maxBytes int64, maxDepth int) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.ContentLength > maxBytes { + abortBodyTooLarge(c) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxBytes+1)) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, utils.ErrorStr("Invalid request body. Please check your input and try again.")) + return + } + + if int64(len(body)) > maxBytes { + abortBodyTooLarge(c) + return + } + + if err := checkJSONDepth(body, maxDepth); err != nil { + if errors.Is(err, errJSONTooDeep) { + c.AbortWithStatusJSON(http.StatusBadRequest, utils.ErrorStr("Request is nested too deeply.")) + } else { + c.AbortWithStatusJSON(http.StatusBadRequest, utils.ErrorStr("Invalid request body: malformed JSON")) + } + return + } + + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Next() + } +} + +func abortBodyTooLarge(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, utils.ErrorStr("Request is too large.")) +} + +func checkJSONDepth(body []byte, maxDepth int) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + + depth := 0 + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + if depth != 0 { + return io.ErrUnexpectedEOF + } + return nil + } + if err != nil { + return err + } + + delim, ok := token.(json.Delim) + if !ok { + continue + } + + switch delim { + case '{', '[': + depth++ + if depth > maxDepth { + return errJSONTooDeep + } + case '}', ']': + depth-- + } + } +} diff --git a/backend/app/http/middleware/limitjsonbody_test.go b/backend/app/http/middleware/limitjsonbody_test.go new file mode 100644 index 00000000..9dfa5fc3 --- /dev/null +++ b/backend/app/http/middleware/limitjsonbody_test.go @@ -0,0 +1,142 @@ +package middleware + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func nestedArrays(depth int) string { + return strings.Repeat("[", depth) + strings.Repeat("]", depth) +} + +func TestLimitJSONBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + const maxBytes = 256 * 1024 + const maxDepth = 16 + + padded := func(size int) string { + overhead := len(`{"a":""}`) + return `{"a":"` + strings.Repeat("x", size-overhead) + `"}` + } + + tests := []struct { + name string + body string + wantStatus int + wantReached bool + }{ + {name: "small valid object", body: `{"a":1}`, wantStatus: http.StatusOK, wantReached: true}, + {name: "empty array", body: `[]`, wantStatus: http.StatusOK, wantReached: true}, + {name: "exactly at the size limit", body: padded(maxBytes), wantStatus: http.StatusOK, wantReached: true}, + {name: "one byte over the size limit", body: padded(maxBytes + 1), wantStatus: http.StatusRequestEntityTooLarge}, + {name: "depth exactly the limit", body: nestedArrays(maxDepth), wantStatus: http.StatusOK, wantReached: true}, + {name: "depth one over the limit", body: nestedArrays(maxDepth + 1), wantStatus: http.StatusBadRequest}, + {name: "mixed object and array depth at limit", body: strings.Repeat(`{"a":[`, maxDepth/2) + strings.Repeat(`]}`, maxDepth/2), wantStatus: http.StatusOK, wantReached: true}, + {name: "5000 deep", body: nestedArrays(5000), wantStatus: http.StatusBadRequest}, + {name: "5000 deep unterminated", body: strings.Repeat("[", 5000), wantStatus: http.StatusBadRequest}, + {name: "invalid JSON", body: `{"a":`, wantStatus: http.StatusBadRequest}, + {name: "garbage", body: `not json`, wantStatus: http.StatusBadRequest}, + {name: "brackets inside strings do not count", body: `{"a":"` + strings.Repeat("[", 100) + `"}`, wantStatus: http.StatusOK, wantReached: true}, + {name: "empty body", body: ``, wantStatus: http.StatusOK, wantReached: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var reached bool + var seen string + + router := gin.New() + router.POST("/", LimitJSONBody(maxBytes, maxDepth), func(c *gin.Context) { + reached = true + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(c.Request.Body) + seen = buf.String() + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, tt.wantStatus, rec.Code) + require.Equal(t, tt.wantReached, reached) + if tt.wantReached { + require.Equal(t, tt.body, seen, "the handler must receive the body unchanged") + } + }) + } +} + +func TestLimitJSONBody_DeclaredContentLengthOverLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + + var reached bool + router := gin.New() + router.POST("/", LimitJSONBody(10, 16), func(c *gin.Context) { + reached = true + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + req.ContentLength = 11 + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.False(t, reached) +} + +type countingReader struct { + io.Reader + read int +} + +func (r *countingReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + r.read += n + return n, err +} + +func TestLimitJSONBody_UnknownContentLengthOverLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + + const maxBytes = 256 * 1024 + + tests := []struct { + name string + size int + }{ + {"one byte over the size limit", maxBytes + 1}, + {"1 MiB", 1024 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var reached bool + router := gin.New() + router.POST("/", LimitJSONBody(maxBytes, 16), func(c *gin.Context) { + reached = true + c.Status(http.StatusOK) + }) + + // Not a *strings.Reader, so httptest leaves ContentLength at -1 like a chunked request. + body := &countingReader{Reader: strings.NewReader(`{"a":"` + strings.Repeat("x", tt.size-len(`{"a":""}`)) + `"}`)} + req := httptest.NewRequest(http.MethodPost, "/", body) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, int64(-1), req.ContentLength) + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.False(t, reached) + require.LessOrEqual(t, body.read, maxBytes+1) + }) + } +} diff --git a/backend/app/http/server.go b/backend/app/http/server.go index 483cc345..2c43a203 100644 --- a/backend/app/http/server.go +++ b/backend/app/http/server.go @@ -54,6 +54,11 @@ import ( "go.uber.org/zap" ) +const ( + panelBodyMaxBytes = 256 * 1024 + panelBodyMaxJSONDepth = 16 +) + func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server { logger.Info("Starting HTTP server") @@ -193,6 +198,8 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server } } + panelWriteLimit := middleware.LimitJSONBody(panelBodyMaxBytes, panelBodyMaxJSONDepth) + guildAuthApiAdmin := apiGroup.Group("/:id", middleware.AuthenticateGuild(permission.Admin), middleware.SentryUser) guildAuthApiSupport := apiGroup.Group("/:id", middleware.AuthenticateGuild(permission.Support), middleware.SentryUser) guildApiNoAuth := apiGroup.Group("/:id", middleware.ParseGuildId, middleware.SentryUser) @@ -224,9 +231,9 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server guildAuthApiSupport.GET("/panels", api_panels.ListPanels) guildAuthApiSupport.GET("/panels/:panelid", api_panels.GetPanel) guildAuthApiAdmin.GET("/panels/perm-check", api_panels.PermCheckHandler) - guildAuthApiAdmin.POST("/panels", api_panels.CreatePanel) + guildAuthApiAdmin.POST("/panels", panelWriteLimit, api_panels.CreatePanel) guildAuthApiAdmin.POST("/panels/:panelid", rl(middleware.RateLimitTypeGuild, 5, 5*time.Second), api_panels.ResendPanel) - guildAuthApiAdmin.PATCH("/panels/:panelid", api_panels.UpdatePanel) + guildAuthApiAdmin.PATCH("/panels/:panelid", panelWriteLimit, api_panels.UpdatePanel) guildAuthApiAdmin.DELETE("/panels/:panelid", api_panels.DeletePanel) guildAuthApiAdmin.DELETE("/panels/:panelid/cooldowns", api_panels.ResetPanelCooldowns) @@ -239,9 +246,9 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server guildAuthApiAdmin.GET("/multi-panels", api_panels.MultiPanelList) guildAuthApiAdmin.GET("/multi-panels/:panelid", api_panels.MultiPanelGet) - guildAuthApiAdmin.POST("/multi-panels", api_panels.MultiPanelCreate) + guildAuthApiAdmin.POST("/multi-panels", panelWriteLimit, api_panels.MultiPanelCreate) guildAuthApiAdmin.POST("/multi-panels/:panelid", rl(middleware.RateLimitTypeGuild, 5, 5*time.Second), api_panels.MultiPanelResend) - guildAuthApiAdmin.PATCH("/multi-panels/:panelid", api_panels.MultiPanelUpdate) + guildAuthApiAdmin.PATCH("/multi-panels/:panelid", panelWriteLimit, api_panels.MultiPanelUpdate) guildAuthApiAdmin.DELETE("/multi-panels/:panelid", api_panels.MultiPanelDelete) guildAuthApiSupport.GET("/forms", api_forms.GetForms) diff --git a/backend/go.mod b/backend/go.mod index f74db31c..82c76977 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,13 +2,13 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database -// replace github.com/TicketsBot-cloud/common => ../../common +replace github.com/TicketsBot-cloud/common => ../../common -// replace github.com/TicketsBot-cloud/worker => ../../worker +replace github.com/TicketsBot-cloud/worker => ../../worker -// replace github.com/TicketsBot-cloud/gdl => ../../gdl +replace github.com/TicketsBot-cloud/gdl => ../../gdl replace github.com/go-playground/validator/v10 => github.com/go-playground/validator/v10 v10.14.0 diff --git a/backend/notify/categories.go b/backend/notify/categories.go index 75985c4e..ac6469f8 100644 --- a/backend/notify/categories.go +++ b/backend/notify/categories.go @@ -13,10 +13,10 @@ const ( const CategoryGroupAdmin = "admin" type CategoryInfo struct { - Key string `json:"key"` - Label string `json:"label"` - Description string `json:"description"` - MinTier admin.AdminTier `json:"min_tier"` + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + MinTier admin.AdminTier `json:"min_tier"` } var AllCategories = []CategoryInfo{ diff --git a/frontend/src/components/ColourSelect.tsx b/frontend/src/components/ColourSelect.tsx index da1f7774..964c8f16 100644 --- a/frontend/src/components/ColourSelect.tsx +++ b/frontend/src/components/ColourSelect.tsx @@ -7,6 +7,7 @@ interface ColourSelectBaseProps { disabled?: boolean; className?: string; label?: string; // Optional label + ariaLabel?: string; } interface SolidColourSelectProps extends ColourSelectBaseProps { @@ -28,7 +29,7 @@ interface NullableColourSelectProps extends ColourSelectBaseProps { type ColourSelectProps = SolidColourSelectProps | NullableColourSelectProps; const ColourSelect: React.FC = (props) => { - const { value, disabled = false, className = "", label } = props; + const { value, disabled = false, className = "", label, ariaLabel } = props; const clearable = props.nullable && props.clearable; const fallbackColour = (props.nullable && props.fallbackColour) || "#5865f2"; const inputRef = useRef(null); @@ -92,7 +93,7 @@ const ColourSelect: React.FC = (props) => { id={inputId} ref={inputRef} type="color" - aria-label={label ?? "Select a colour"} + aria-label={label ?? ariaLabel ?? "Select a colour"} aria-labelledby={isNone ? (label ? `${labelId} ${noneId}` : noneId) : undefined} className="flex-1 min-w-0 bg-transparent text-white focus:outline-none py-0.75 rounded cursor-pointer disabled:cursor-not-allowed [&::-webkit-color-swatch]:rounded [&::-webkit-color-swatch]:border-none [&::-moz-color-swatch]:rounded [&::-moz-color-swatch]:border-none" defaultValue={initialColour} diff --git a/frontend/src/components/PanelPreview.tsx b/frontend/src/components/PanelPreview.tsx index f1e252dd..7edc5c10 100644 --- a/frontend/src/components/PanelPreview.tsx +++ b/frontend/src/components/PanelPreview.tsx @@ -1,7 +1,9 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import Button from "@/components/discord/container/Button"; import SelectMenu from "@/components/discord/container/SelectMenu"; import DiscordContent from "@/components/discord/DiscordContent"; +import DiscordComponents from "@/components/discord/DiscordComponents"; +import AutoAppendedTag from "@/components/component-builder/AutoAppendedTag"; import type { Panel, MultiPanel, MultiPanelRequest } from "@/types"; import { BRANDING_FOOTER_ICON, BRANDING_FOOTER_TEXT } from "@/lib/constants"; import { formatEmbedTimestampForDisplay } from "@/lib/embed-timestamp"; @@ -9,6 +11,7 @@ import { isSafeUrl } from "@/lib/url"; import { previewAvatarUrl } from "@/lib/embed-avatar"; import { usePreviewEntities } from "@/hooks/usePreviewEntities"; import { embedBorderColour } from "@/lib/colour"; +import { toPreviewComponents, visibleWelcomeButtons, type V2Component } from "@/lib/component-tree"; function resolveEmoteName(emote: Panel["emote"] | undefined): string { if (typeof emote === "string") return emote; @@ -28,78 +31,110 @@ interface PanelPreviewProps { buttons?: Panel[]; }; brandingFooter?: boolean; + messageMode?: "classic" | "components_v2"; + componentTree?: V2Component[]; + panelsForPreview?: Panel[]; } -const PanelPreview: FC = ({ type, data, brandingFooter }) => { +const PanelPreview: FC = ({ + type, + data, + brandingFooter, + messageMode = "classic", + componentTree, + panelsForPreview, +}) => { const { panel, buttons } = data; const entities = usePreviewEntities(); + const isV2 = messageMode === "components_v2"; + + const buttonsRow = (content: ReactNode) => + !content ? null : isV2 ? ( + {content} + ) : ( +
{content}
+ ); if (type === "panel") { const p = panel as Panel; + const buttonsContent = + buttons && buttons.length > 0 ? ( +
+ {buttons.map((bp, index) => ( +
+ ) : null; + return ( <> -
-
-
- - -
- {p.thumbnail_url && ( -
- Thumbnail -
- )} + {isV2 ? ( +
+
- {p.image_url && ( -
- Embedded -
- )} -
- {buttons && buttons.length > 0 && ( -
- {buttons.map((bp, index) => ( -
- ); - })()} + + )} + {buttonsRow( + buttons && buttons.length > 0 + ? (() => { + const mp = panel as MultiPanel | MultiPanelRequest; + if (mp.select_menu) { + const options = buttons.map((bp) => { + const entry = mp.panels.find((e) => e.panel_id === bp.panel_id); + const emoji = bp.use_custom_emoji + ? bp.emoji_id + ? { + name: resolveEmoteName(bp.emote), + id: bp.emoji_id, + animated: bp.emoji_animated, + } + : undefined + : resolveEmoteName(bp.emote) + ? { name: resolveEmoteName(bp.emote) } + : undefined; + return { + value: String(bp.panel_id), + label: bp.button_label || "Open Ticket", + description: entry?.description, + emoji, + }; + }); + return ( +
+ +
+ ); + } + return ( +
+ {buttons.map((p, index) => ( +
+ ); + })() + : null, + )} ); }; diff --git a/frontend/src/components/SupportHoursForm.tsx b/frontend/src/components/SupportHoursForm.tsx index abfc7dca..7dc526c4 100644 --- a/frontend/src/components/SupportHoursForm.tsx +++ b/frontend/src/components/SupportHoursForm.tsx @@ -215,9 +215,8 @@ const SupportHoursForm: FC = ({ value, onChange }) => { }; const handleMessageChange = (val: string) => { - const clamped = val.slice(0, 500); - setOutOfHoursMessage(clamped); - emitChange(hours, timezone, outOfHoursBehaviour, outOfHoursTitle, clamped, outOfHoursColour); + setOutOfHoursMessage(val); + emitChange(hours, timezone, outOfHoursBehaviour, outOfHoursTitle, val, outOfHoursColour); }; const handleColourChange = (val: string | null) => { diff --git a/frontend/src/components/TextInput.tsx b/frontend/src/components/TextInput.tsx index 6b8ab2e5..4de9c019 100644 --- a/frontend/src/components/TextInput.tsx +++ b/frontend/src/components/TextInput.tsx @@ -6,6 +6,7 @@ import { MISSING_FIELD_CLASS, isBlank, } from "@/lib/field-validity"; +import { clampCodePoints, codePointMaxLength } from "@/lib/input-length"; interface TextInputProps { value: string; @@ -86,13 +87,19 @@ const TextInput: FC = (props) => { autoComplete={autoComplete} className="w-full bg-transparent text-white focus:outline-none p-2" value={value} - onChange={(e) => onChange(e.target.value)} + onChange={(e) => + onChange( + maxLength === undefined + ? e.target.value + : clampCodePoints(value, e.target.value, maxLength), + ) + } onKeyDown={onKeyDown} onBlur={onBlur} autoFocus={autoFocus} placeholder={placeholder} disabled={disabled} - maxLength={maxLength} + maxLength={maxLength === undefined ? undefined : codePointMaxLength(maxLength, value)} inputMode={inputMode} pattern={pattern} aria-describedby={ diff --git a/frontend/src/components/Textarea.tsx b/frontend/src/components/Textarea.tsx index 42da0ecf..52bf9417 100644 --- a/frontend/src/components/Textarea.tsx +++ b/frontend/src/components/Textarea.tsx @@ -6,6 +6,7 @@ import { MISSING_FIELD_CLASS, isBlank, } from "@/lib/field-validity"; +import { clampCodePoints, codePointMaxLength } from "@/lib/input-length"; interface TextareaProps { value: string; @@ -60,11 +61,11 @@ const Textarea: FC = (props) => { id={textareaId} className="w-full bg-gray-700 p-3 rounded resize-y h-50 focus:outline-none" value={safeValue} - onChange={(e) => onChange(e.target.value)} + onChange={(e) => onChange(clampCodePoints(safeValue, e.target.value, max))} onBlur={onBlur} disabled={disabled} minLength={min} - maxLength={max} + maxLength={codePointMaxLength(max, safeValue)} placeholder={placeholder || ""} aria-invalid={error ? true : undefined} aria-required={required || undefined} diff --git a/frontend/src/components/component-builder/AddBlockMenu.tsx b/frontend/src/components/component-builder/AddBlockMenu.tsx new file mode 100644 index 00000000..59541dcc --- /dev/null +++ b/frontend/src/components/component-builder/AddBlockMenu.tsx @@ -0,0 +1,216 @@ +import { + Fragment, + useEffect, + useId, + useRef, + useState, + type FC, + type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Button from "@/components/Button"; +import { useFloatingDropdown } from "@/hooks/useFloatingDropdown"; +import { + blockLabel, + hasPanelSelectBlock, + type V2Component, + type V2ComponentType, +} from "@/lib/component-tree"; +import { BLOCK_ICONS } from "./block-icons"; +import type { PickerPanel } from "./picker-panels"; + +interface AddBlockMenuProps { + onAdd: (type: V2ComponentType) => void; + exclude?: V2ComponentType[]; + tree: V2Component[]; + availablePanels?: PickerPanel[]; + selectMenuModeOn?: boolean; + containerLabel?: string; + focusId?: string; +} + +const LAYOUT_TYPES: V2ComponentType[] = [ + "container", + "section", + "separator", + "button_row", + "panel_select", +]; +const CONTENT_TYPES: V2ComponentType[] = ["text_display", "media_gallery"]; + +const AddBlockMenu: FC = ({ + onAdd, + exclude = [], + tree, + availablePanels, + selectMenuModeOn, + containerLabel, + focusId, +}) => { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + const { position } = useFloatingDropdown({ + isOpen, + triggerRef, + dropdownRef, + onClose: () => setIsOpen(false), + maxHeight: 320, + minWidth: 220, + }); + + const closeAndReturnFocus = () => { + setIsOpen(false); + // Deferred: an add can exhaust the block budget and unmount this trigger in the same update. + requestAnimationFrame(() => triggerRef.current?.focus()); + }; + + useEffect(() => { + // The dropdown is portaled to the end of document.body, so tabbing would skip past it. + if (!isOpen) return; + dropdownRef.current + ?.querySelector('button:not([aria-disabled="true"])') + ?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeAndReturnFocus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + const handlePopupKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + const items = Array.from( + e.currentTarget.querySelectorAll('button:not([aria-disabled="true"])'), + ); + const leaving = e.shiftKey ? e.target === items[0] : e.target === items[items.length - 1]; + if (!leaving) return; + // The popup is portaled to the end of the page, so Tab would leave it somewhere unrelated. + e.preventDefault(); + setIsOpen(false); + triggerRef.current?.focus(); + }; + + const handlePopupBlur = (e: ReactFocusEvent) => { + const next = e.relatedTarget; + // A null relatedTarget is a click on non-focusable popup chrome or a window switch. + if (!(next instanceof Node)) return; + if (dropdownRef.current?.contains(next) || triggerRef.current?.contains(next)) return; + setIsOpen(false); + }; + + const panelSelectAlreadyPlaced = hasPanelSelectBlock(tree); + + const layoutTypes = LAYOUT_TYPES.filter((t) => { + if (exclude.includes(t)) return false; + if (t === "panel_select" && selectMenuModeOn === undefined) return false; + return true; + }); + const contentTypes = CONTENT_TYPES.filter((t) => !exclude.includes(t)); + + const disabledReason = (type: V2ComponentType): string | undefined => { + if (type !== "panel_select") return undefined; + if (selectMenuModeOn === false) return "Enable dropdown mode in Ticket Settings to add this"; + if (panelSelectAlreadyPlaced) return "Only one panel select block is allowed"; + if (availablePanels?.length === 0) return "Every panel is already placed as a button"; + return undefined; + }; + + const renderGroup = (title: string, types: V2ComponentType[]) => { + if (types.length === 0) return null; + return ( +
+

{title}

+ {types.map((type) => { + const reason = disabledReason(type); + const reasonId = `${menuId}-${type}-reason`; + return ( + + + {reason && ( + + )} + + ); + })} +
+ ); + }; + + return ( + <> + + {isOpen && + createPortal( + // The handlers only manage focus leaving the group; the items inside are the controls. + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+ {renderGroup("Layout", layoutTypes)} + {renderGroup("Content", contentTypes)} +
, + document.body, + )} + + ); +}; + +export default AddBlockMenu; diff --git a/frontend/src/components/component-builder/AutoAppendedTag.tsx b/frontend/src/components/component-builder/AutoAppendedTag.tsx new file mode 100644 index 00000000..73d2acb7 --- /dev/null +++ b/frontend/src/components/component-builder/AutoAppendedTag.tsx @@ -0,0 +1,17 @@ +import type { FC, ReactNode } from "react"; + +interface AutoAppendedTagProps { + children: ReactNode; + className?: string; +} + +const AutoAppendedTag: FC = ({ children, className = "" }) => ( +
+ + Added automatically + + {children} +
+); + +export default AutoAppendedTag; diff --git a/frontend/src/components/component-builder/BlockList.tsx b/frontend/src/components/component-builder/BlockList.tsx new file mode 100644 index 00000000..08f6882a --- /dev/null +++ b/frontend/src/components/component-builder/BlockList.tsx @@ -0,0 +1,234 @@ +import { useId, type FC } from "react"; +import { + addBlockAtTop, + addBlockToContainer, + blockLabel, + countBlocks, + createEmptyBlock, + EMPTY_PANEL_SELECT_MESSAGE, + getTopLevelContainers, + moveBlock, + moveBlockIntoContainer, + moveBlockOutOfContainer, + removeBlock, + updateBlock, + type V2Component, +} from "@/lib/component-tree"; +import { pendingBlockFocus } from "@/lib/block-focus"; +import BlockShell from "./BlockShell"; +import AddBlockMenu from "./AddBlockMenu"; +import ContainerEditor from "./ContainerEditor"; +import SectionEditor from "./SectionEditor"; +import TextDisplayEditor from "./TextDisplayEditor"; +import MediaGalleryEditor from "./MediaGalleryEditor"; +import SeparatorEditor from "./SeparatorEditor"; +import ButtonRowEditor from "./ButtonRowEditor"; +import { withOwnPanel, type PickerPanel } from "./picker-panels"; +import { BLOCK_ICONS } from "./block-icons"; + +const PanelSelectSummary: FC<{ availablePanels?: PickerPanel[] }> = ({ availablePanels }) => { + const names = (availablePanels ?? []).map((p) => p.label); + if (names.length === 0) { + return

{EMPTY_PANEL_SELECT_MESSAGE}

; + } + const fullList = names.join(", "); + return ( +

+ Contains: {fullList} +

+ ); +}; + +interface BlockListProps { + blocks: V2Component[]; + tree: V2Component[]; + onTreeChange: (next: V2Component[]) => void; + allowAvatarPlaceholder?: boolean; + nested: boolean; + containerId?: string; + containerLabel?: string; + onAnnounce: (message: string) => void; + disabled?: boolean; + budgetMax: number; + availablePanels?: PickerPanel[]; + allPanels?: PickerPanel[]; + selectMenuModeOn?: boolean; + saveAttempted?: boolean; + blockErrors?: Map; +} + +const BlockList: FC = ({ + blocks, + tree, + onTreeChange, + allowAvatarPlaceholder, + nested, + containerId, + containerLabel, + onAnnounce, + disabled, + budgetMax, + availablePanels, + allPanels, + selectMenuModeOn, + saveAttempted, + blockErrors, +}) => { + const addMenuFocusId = useId(); + const topLevelContainers = nested ? [] : getTopLevelContainers(tree); + const budgetExhausted = countBlocks(tree) >= budgetMax; + const canAddMore = !disabled && !budgetExhausted; + + return ( +
+ {blocks.map((block, index) => { + const canMoveUp = index > 0; + const canMoveDown = index < blocks.length - 1; + const label = blockLabel(block.type); + const shellLabel = + blocks.length > 1 ? `${label} (${index + 1} of ${blocks.length})` : label; + + const handleMove = (direction: "up" | "down") => { + onTreeChange(moveBlock(tree, block.id, direction)); + const newPosition = direction === "up" ? index : index + 2; + onAnnounce(`Moved ${label} ${direction} to position ${newPosition} of ${blocks.length}`); + }; + + const handleMoveIntoContainer = (targetContainerId: string) => { + const next = moveBlockIntoContainer(tree, block.id, targetContainerId); + const target = getTopLevelContainers(next).find((c) => c.id === targetContainerId); + pendingBlockFocus.id = block.id; + onTreeChange(next); + onAnnounce(`Moved ${label} into ${target?.label ?? "container"}`); + }; + + const handleMoveOutOfContainer = () => { + pendingBlockFocus.id = block.id; + onTreeChange(moveBlockOutOfContainer(tree, block.id)); + onAnnounce(`Moved ${label} out of ${containerLabel ?? "container"} to top level`); + }; + + const handleDelete = () => { + pendingBlockFocus.id = + (blocks[index + 1] ?? blocks[index - 1])?.id ?? containerId ?? addMenuFocusId; + onTreeChange(removeBlock(tree, block.id)); + onAnnounce(`Deleted ${label}`); + }; + + const handleUpdate = (updated: V2Component) => + onTreeChange(updateBlock(tree, block.id, () => updated)); + + return ( + handleMove("up")} + onMoveDown={() => handleMove("down")} + moveIntoContainerTargets={ + !nested && block.type !== "container" ? topLevelContainers : undefined + } + onMoveIntoContainer={handleMoveIntoContainer} + canMoveOutOfContainer={nested} + onMoveOutOfContainer={handleMoveOutOfContainer} + onDelete={handleDelete} + nested={nested} + disabled={disabled} + errors={saveAttempted ? blockErrors?.get(block.id) : undefined} + > + {block.type === "container" && ( + + )} + {block.type === "section" && ( + + )} + {block.type === "text_display" && ( + + )} + {block.type === "media_gallery" && ( + + )} + {block.type === "separator" && ( + + )} + {block.type === "button_row" && ( + + )} + {block.type === "panel_select" && ( + + )} + + ); + })} + {canAddMore && ( + { + const newBlock = createEmptyBlock(type); + const next = + nested && containerId + ? addBlockToContainer(tree, containerId, newBlock) + : addBlockAtTop(tree, newBlock); + // Taking the last slot unmounts every Add block trigger, so focus would otherwise be lost. + if (countBlocks(next) >= budgetMax) pendingBlockFocus.id = newBlock.id; + onTreeChange(next); + onAnnounce(`Added ${blockLabel(type)}${containerLabel ? ` to ${containerLabel}` : ""}`); + }} + /> + )} + {budgetExhausted && !disabled && !nested && ( +

No space left for more blocks.

+ )} +
+ ); +}; + +export default BlockList; diff --git a/frontend/src/components/component-builder/BlockShell.tsx b/frontend/src/components/component-builder/BlockShell.tsx new file mode 100644 index 00000000..e7c13259 --- /dev/null +++ b/frontend/src/components/component-builder/BlockShell.tsx @@ -0,0 +1,178 @@ +import { useId, useLayoutEffect, useRef, useState, type FC, type ReactNode } from "react"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronUp, faChevronDown, faTrash } from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; + +interface MoveTarget { + id: string; + label: string; +} + +interface BlockShellProps { + blockId: string; + label: string; + containerLabel?: string; + icon: IconDefinition; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + moveIntoContainerTargets?: MoveTarget[]; + onMoveIntoContainer?: (containerId: string) => void; + canMoveOutOfContainer: boolean; + onMoveOutOfContainer?: () => void; + onDelete: () => void; + nested?: boolean; + disabled?: boolean; + errors?: string[]; + children: ReactNode; +} + +const BlockShell: FC = ({ + blockId, + label, + containerLabel, + icon, + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + moveIntoContainerTargets, + onMoveIntoContainer, + canMoveOutOfContainer, + onMoveOutOfContainer, + onDelete, + nested, + disabled, + errors, + children, +}) => { + const errorId = useId(); + const upRef = useRef(null); + const downRef = useRef(null); + const pendingFocus = useRef<"up" | "down" | null>(null); + const [moveTarget, setMoveTarget] = useState(""); + const fullLabel = containerLabel ? `${containerLabel}, ${label}` : label; + const chosenTarget = moveIntoContainerTargets?.some((target) => target.id === moveTarget) + ? moveTarget + : ""; + + // Reordering moves the DOM nodes and can disable the pressed arrow, both of which drop focus. + useLayoutEffect(() => { + const pressed = pendingFocus.current; + if (!pressed) return; + pendingFocus.current = null; + const same = pressed === "up" ? upRef.current : downRef.current; + const opposite = pressed === "up" ? downRef.current : upRef.current; + (same && !same.disabled ? same : opposite)?.focus(); + }); + + return ( +
+
+
+
+
+ {canMoveOutOfContainer && onMoveOutOfContainer && ( + + )} + {!!moveIntoContainerTargets?.length && onMoveIntoContainer && ( +
+ + +
+ )} + + + +
+
+ {!!errors?.length && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} + {children} +
+ ); +}; + +export default BlockShell; diff --git a/frontend/src/components/component-builder/BuildPreviewTabs.tsx b/frontend/src/components/component-builder/BuildPreviewTabs.tsx new file mode 100644 index 00000000..c8f4f352 --- /dev/null +++ b/frontend/src/components/component-builder/BuildPreviewTabs.tsx @@ -0,0 +1,88 @@ +import { + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, + type FC, + type ReactNode, +} from "react"; +import { flushSync } from "react-dom"; +import Tabs from "@/components/Tabs"; +import { FIELD_REVEAL_EVENT } from "@/lib/scroll-to-missing"; + +interface BuildPreviewTabsProps { + build: ReactNode; + preview: ReactNode; + className?: string; + labelSuffix?: string; +} + +const NARROW_QUERY = "(width < 48rem)"; +const subscribeNarrow = (onChange: () => void) => { + const query = window.matchMedia(NARROW_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); +}; +const isNarrow = () => window.matchMedia(NARROW_QUERY).matches; + +/** Renders three siblings so a parent `md:grid-cols-2` lays the panes out together from `md` up. */ +const BuildPreviewTabs: FC = ({ + build, + preview, + className = "", + labelSuffix, +}) => { + const [activeTab, setActiveTab] = useState<"build" | "preview">("build"); + const uid = useId(); + const buildKey = `${uid}-build`; + const previewKey = `${uid}-preview`; + const buildPanelRef = useRef(null); + const narrow = useSyncExternalStore(subscribeNarrow, isNarrow); + + // A hidden Build pane can't take focus, so it is shown synchronously before the caller focuses a field. + useEffect(() => { + const reveal = (e: Event) => { + const target = (e as CustomEvent).detail; + if (target && buildPanelRef.current?.contains(target)) { + flushSync(() => setActiveTab("build")); + } + }; + window.addEventListener(FIELD_REVEAL_EVENT, reveal); + return () => window.removeEventListener(FIELD_REVEAL_EVENT, reveal); + }, []); + + return ( + <> + setActiveTab(key === buildKey ? "build" : "preview")} + /> +
+ {build} +
+
+ {preview} +
+ + ); +}; + +export default BuildPreviewTabs; diff --git a/frontend/src/components/component-builder/ButtonRowEditor.tsx b/frontend/src/components/component-builder/ButtonRowEditor.tsx new file mode 100644 index 00000000..a68aef80 --- /dev/null +++ b/frontend/src/components/component-builder/ButtonRowEditor.tsx @@ -0,0 +1,222 @@ +import { useId, type FC } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCheck, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; +import Select from "@/components/Select"; +import TextInput from "@/components/TextInput"; +import { useFocusAfterRemove } from "@/hooks/useFocusAfterRemove"; +import { useTouchedFields } from "@/hooks/useTouchedFields"; +import { + MAX_BUTTON_LABEL_LENGTH, + MAX_ROW_BUTTONS, + MAX_URL_LENGTH, + linkLabelError, + linkUrlError, + type V2ButtonRow, + type V2ButtonRowButton, +} from "@/lib/component-tree"; +import PanelButtonTag from "./PanelButtonTag"; +import { THIS_PANEL_PICKER, withOwnPanel, type PickerPanel } from "./picker-panels"; + +interface ButtonRowEditorProps { + value: V2ButtonRow; + onChange: (updated: V2ButtonRow) => void; + unplacedPanels?: PickerPanel[]; + allPanels?: PickerPanel[]; + saveAttempted?: boolean; +} + +const ButtonRowEditor: FC = ({ + value, + onChange, + unplacedPanels, + allPanels = [], + saveAttempted = false, +}) => { + const hintId = useId(); + const { shows, touch } = useTouchedFields(saveAttempted); + const { targetRef: addRef, request: focusAddButton } = useFocusAfterRemove(); + const updateLinkField = (index: number, update: Partial<{ label: string; url: string }>) => { + const buttons = value.buttons.map((b, i) => { + if (i !== index || b.kind === "panel") return b; + return { ...b, ...update }; + }); + onChange({ ...value, buttons }); + }; + + const setPanelId = (index: number, panelId: number) => { + const buttons = value.buttons.map((b, i) => + i === index && b.kind === "panel" ? { ...b, panel_id: panelId } : b, + ); + onChange({ ...value, buttons }); + }; + + const setButtonMode = (index: number, kind: "link" | "panel") => { + const buttons = value.buttons.map((b, i): V2ButtonRowButton => { + if (i !== index) return b; + const currentlyPanel = b.kind === "panel"; + if (kind === "panel") { + if (currentlyPanel) return b; + const panelId = unplacedPanels?.[0]?.panel_id; + if (panelId === undefined) return b; + return { id: b.id, kind: "panel", panel_id: panelId }; + } + if (!currentlyPanel) return b; + return { id: b.id, kind: "link", label: "", url: "" }; + }); + onChange({ ...value, buttons }); + }; + + const removeButton = (index: number) => { + focusAddButton(); + onChange({ ...value, buttons: value.buttons.filter((_, i) => i !== index) }); + }; + + const addButton = () => { + if (value.buttons.length >= MAX_ROW_BUTTONS) return; + onChange({ + ...value, + buttons: [...value.buttons, { id: crypto.randomUUID(), label: "", url: "" }], + }); + }; + + return ( +
+ {value.buttons.map((b, i) => { + const isPanelMode = b.kind === "panel"; + const noneUnplaced = unplacedPanels?.length === 0; + const panelOptions = + b.kind === "panel" && unplacedPanels + ? withOwnPanel(unplacedPanels, allPanels, b.panel_id) + : (unplacedPanels ?? []); + + return ( +
+
+ {unplacedPanels !== undefined && ( +
+ + + {!isPanelMode && noneUnplaced && ( + + )} +
+ )} + + {isPanelMode ? ( +
+ {b.panel_id === THIS_PANEL_PICKER.panel_id ? ( + {THIS_PANEL_PICKER.label} + ) : ( + onChange(option.mode)} + /> + {locked && ( + + + )} +
+
+
+
+ {option.title} + {selected && ( +
+

{option.description}

+
+ + ); + })} +
+ + ); +}; + +export default MessageModeToggle; diff --git a/frontend/src/components/component-builder/PanelButtonTag.tsx b/frontend/src/components/component-builder/PanelButtonTag.tsx new file mode 100644 index 00000000..82918f7c --- /dev/null +++ b/frontend/src/components/component-builder/PanelButtonTag.tsx @@ -0,0 +1,10 @@ +import type { FC } from "react"; + +// Not AutoAppendedTag, whose pointer-events-none would disable the neighbouring picker. +const PanelButtonTag: FC<{ thisPanel?: boolean }> = ({ thisPanel }) => ( + + {thisPanel ? "Edited below" : "Edited in Panel Customization"} + +); + +export default PanelButtonTag; diff --git a/frontend/src/components/component-builder/SectionEditor.tsx b/frontend/src/components/component-builder/SectionEditor.tsx new file mode 100644 index 00000000..77aa8c56 --- /dev/null +++ b/frontend/src/components/component-builder/SectionEditor.tsx @@ -0,0 +1,319 @@ +import { useId, useLayoutEffect, useRef, type FC } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faTrash, faImage, faLink, faTicket } from "@fortawesome/free-solid-svg-icons"; +import Button from "@/components/Button"; +import Select from "@/components/Select"; +import TextInput from "@/components/TextInput"; +import Textarea from "@/components/Textarea"; +import Slider from "@/components/Slider"; +import { useFocusAfterRemove } from "@/hooks/useFocusAfterRemove"; +import { useTouchedFields } from "@/hooks/useTouchedFields"; +import { + MAX_ALT_TEXT_LENGTH, + MAX_BUTTON_LABEL_LENGTH, + MAX_SECTION_TEXTS, + MAX_TEXT_CHARACTERS, + MAX_URL_LENGTH, + blankTextError, + linkLabelError, + linkUrlError, + mediaUrlError, + type V2Accessory, + type V2Section, + type V2TextChild, +} from "@/lib/component-tree"; +import PanelButtonTag from "./PanelButtonTag"; +import { THIS_PANEL_PICKER, type PickerPanel } from "./picker-panels"; + +interface SectionEditorProps { + value: V2Section; + onChange: (updated: V2Section) => void; + allowAvatarPlaceholder?: boolean; + availablePanels?: PickerPanel[]; + saveAttempted?: boolean; +} + +const SectionEditor: FC = ({ + value, + onChange, + allowAvatarPlaceholder, + availablePanels, + saveAttempted = false, +}) => { + const hintId = useId(); + const countId = useId(); + const { shows, touch } = useTouchedFields(saveAttempted); + const { targetRef: addTextRef, request: focusAddText } = useFocusAfterRemove(); + const { targetRef: firstAccessoryRef, request: focusAccessoryChoices } = + useFocusAfterRemove(); + const accessoryRef = useRef(null); + const focusNewAccessory = useRef(false); + + // TextInput and Select take no ref, so the new accessory's first control is looked up. + useLayoutEffect(() => { + if (!focusNewAccessory.current) return; + focusNewAccessory.current = false; + accessoryRef.current?.querySelector("input, button")?.focus(); + }); + + const chooseAccessory = (accessory: V2Accessory) => { + focusNewAccessory.current = true; + onChange({ ...value, accessory }); + }; + + const updateText = (index: number, content: string) => { + const components = value.components.map((c, i) => (i === index ? { ...c, content } : c)); + onChange({ ...value, components }); + }; + + const removeText = (index: number) => { + focusAddText(); + onChange({ ...value, components: value.components.filter((_, i) => i !== index) }); + }; + + const addText = () => { + if (value.components.length >= MAX_SECTION_TEXTS) return; + const newChild: V2TextChild = { id: crypto.randomUUID(), content: "" }; + onChange({ ...value, components: [...value.components, newChild] }); + }; + + const thumbnailAccessory = value.accessory?.kind === "thumbnail" ? value.accessory : undefined; + const linkButtonAccessory = value.accessory?.kind === "link_button" ? value.accessory : undefined; + const panelButtonAccessory = + value.accessory?.kind === "panel_button" ? value.accessory : undefined; + + return ( +
+ {value.components.map((child, i) => ( +
+