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/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 9231f02b..90f2a50e 100644 --- a/backend/app/http/endpoints/api/gallery/galleryimport.go +++ b/backend/app/http/endpoints/api/gallery/galleryimport.go @@ -124,9 +124,13 @@ func ImportHandler(ctx *gin.Context) { buttonStyle = int(*listing.ButtonStyle) } + // The listing's Name labels the template; Title is end-user copy such as "Need help?". + 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/panel/bodykeys.go b/backend/app/http/endpoints/api/panel/bodykeys.go new file mode 100644 index 00000000..8e28c1d0 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/bodykeys.go @@ -0,0 +1,128 @@ +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 send back the GET response, where trees are JSON strings. +// Dropping those keeps the stored tree; parsing them would get past LimitJSONBody's depth check. +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) +} + +// 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 +} + +// API clients from before Components V2 and Name leave these keys out. Reading their absence as +// false or null would turn a V2 message back to Classic and clear the name on every save. A Classic +// message's retained tree is never parsed, so it can't fail a save that doesn't use it. +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 (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..9dfcb92f --- /dev/null +++ b/backend/app/http/endpoints/api/panel/bodykeys_test.go @@ -0,0 +1,292 @@ +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" +) + +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 are matched case-insensitively, as binding does", 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, including false and null", 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("a corrupt stored V2 tree fails only when its key is absent", 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, including false and null", 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("a corrupt stored V2 tree fails only when its key is absent", 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.keepStoredWhenAbsent(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)) + }) +} + +// The body a dashboard from before Components V2 sends: the GET response minus its read-only keys. +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")} + 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) + + var data panelBody + keys, err := bindTestBody(t, string(body), &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.keepStoredWhenAbsent(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) + }) +} 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..7bc6ca33 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/componentvalidation.go @@ -0,0 +1,496 @@ +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 + + // Discord allows 40 components in total in a Components V2 message, counting every + // nested component, with no per-level cap. + maxMessageComponents = 40 + + // Discord counts only text display content (section text included) towards this. + maxTreeTextLength = 4000 + maxComponentUrlLength = 512 + maxMediaDescriptionLength = 1024 + maxButtonLabelLength = 80 + maxActionRowChildren = 5 + maxSectionTextDisplays = 3 + maxGalleryItems = 10 + + // A panel placed inside a multi-panel tree is a button carrying this custom_id prefix + // followed by the decimal panel id. It is a marker only and is replaced by the real + // panel button before anything is sent to Discord. + panelPlacementPrefix = "panel:" + + // The same custom_id the live dropdown uses, so a placed dropdown node and the real + // select menu are interchangeable. + panelDropdownCustomId = "multipanel" + + panelMessageReserved = 2 // action row + open ticket button + welcomeFormAnswersReserved = 5 + dropdownReserved = 2 // action row + select menu +) + +type treeRules struct { + validPanelIds map[int]bool + dropdownMode bool + reserved int + // The worker resolves the opener's avatar only when it sends a welcome message. + allowAvatarPlaceholder bool +} + +type treeValidator struct { + rules treeRules + count int + textLength int + placed map[int]bool + selectPlaced bool +} + +type treeResult struct { + placed map[int]bool + dropdownUsed bool + count int +} + +// A caller that only learns the reservation from the tree itself passes reserved 0 and then +// calls checkComponentBudget. +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, 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: a container cannot be placed inside another container") + } + + if len(d.Components) == 0 { + return validation.NewInvalidInputError("Container: a 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: a thumbnail can only be used as a section's accessory") + case component.File: + return validation.NewInvalidInputError("File: file components are 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: a 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: a 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: a section needs an accessory, either a thumbnail or a button") + 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: text cannot be blank") + } + + 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: a 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: an action row cannot be empty") + } + + if len(row.Components) > maxActionRowChildren { + return validation.NewInvalidInputErrorf("Action row: an 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 the only component in its action row") + } + + if err := v.dropdown(d); err != nil { + return err + } + default: + return validation.NewInvalidInputError("Action row: an 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: a button is either a link button or a panel button, never both") + } + + if b.Url != nil || b.Style == component.ButtonStyleLink { + return v.linkButton(b) + } + + panelId, ok := parsePanelPlacement(b.CustomId) + if !ok { + return validation.NewInvalidInputError("Button: buttons must be link buttons or panel buttons") + } + + if v.rules.validPanelIds == nil { + return validation.NewInvalidInputError("Panel button: panels can only be placed in a multi-panel message") + } + + 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: a link button needs the link style and a URL") + } + + if b.SkuId != nil { + return validation.NewInvalidInputError("Link button: a link button cannot have a SKU") + } + + // The editor cannot set one, and resolving a custom emoji needs the bot's view of the + // guild, which tree validation does not have. + if b.Emoji != nil { + return validation.NewInvalidInputError("Link button: link buttons 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) + } + + 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: the panel dropdown can only be placed in a multi-panel that uses a dropdown") + } + + if v.selectPlaced { + return validation.NewInvalidInputError("Dropdown: the panel 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 && utf8.RuneCountInString(*description) > maxMediaDescriptionLength { + return validation.NewInvalidInputErrorf("%s: the alt text must be %d characters or fewer", kind, maxMediaDescriptionLength) + } + + 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 +} + +// Discord refuses dotless hosts and empty labels. A trailing dot (fully qualified) is fine. +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 +} + +// isBidiControl matches the invisible directional overrides that make a link read as a +// different address from the one it opens. +func isBidiControl(r rune) bool { + return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) || r == 0x200E || r == 0x200F +} + +// parsePanelPlacement accepts only the canonical decimal form, so "panel:05" and "panel:5" +// cannot both pass as separate placements of the same panel. +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 +} + +// normaliseComponentTree rebuilds a validated tree keeping only the fields the editor can +// set, so client-supplied extras are neither stored nor forwarded to Discord. It must run +// after validateComponentTree: it assumes every node is a supported type. +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} + } + + // The style is cosmetic here: the real panel button replaces this node when sent. + 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..d2220515 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/componentvalidation_test.go @@ -0,0 +1,454 @@ +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 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} + +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"}, + + // Component count, no reservation. + {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)...)}}, + + // Total text. + {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 do not count towards 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))}}, + + // URLs. + {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 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"}, + + // Container accent colour and separator spacing. + {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"}, + + // Thumbnail and gallery. + {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 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: "allowing the placeholder still rejects other bad media urls", 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"}, + + // Section. + {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 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"}, + + // File, container. + {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{})}}, + + // Buttons. + {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 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: "never both"}, + {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: "never both"}, + {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: "bare button outside a row", tree: []component.Component{linkBtn("Go", "https://example.com")}, wantErr: "action row"}, + + // Dropdown. + {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: "only component"}, + {name: "bare dropdown outside a row", tree: []component.Component{dropdownNode()}, rules: multiPanelRules, wantErr: "action row"}, + + // 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"}, + + // Interactive and unsupported types. + {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) + // text, container, row, 2 buttons, section, text, accessory, row, select + require.Equal(t, 10, 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: "placement button with an unsupported 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..4232af94 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/messagedata_test.go @@ -0,0 +1,486 @@ +package api + +import ( + "encoding/json" + "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/stretchr/testify/require" + "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.Equal(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 to the authored tree", 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("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) { + d := &multiPanelMessageData{Embed: embed.NewEmbed()} + 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) + }) +} + +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 TestBuildComponents_V2(t *testing.T) { + panels := []database.PanelWithCustomization{testPanel(1, "One", "c1"), testPanel(2, "Two", "c2"), testPanel(3, "Three", "c3")} + + t.Run("placed panels are resolved and the rest are 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, wherever the buttons sit", 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("placed and appended 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("a dropdown with nothing left to list is 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("a placement of a panel no longer in the multi-panel is 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("a link button and a section with a link accessory are 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 with no submitted tree 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..b07d9ead 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" @@ -17,6 +18,7 @@ import ( "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/rpc" "github.com/ticketsbot-cloud/dashboard/backend/rpc/cache" "github.com/ticketsbot-cloud/dashboard/backend/utils" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" @@ -39,20 +41,127 @@ 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, so the dropdown would have nothing to list") + } + + return checkComponentBudget(result.count, multiPanelReserved(data.SelectMenu, result.dropdownUsed, unplaced)) +} + +// multiPanelReserved is exactly what buildComponents appends after the authored tree. +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 + } +} + +// Removing a panel from the list must not fail the save over a placement the user cannot see. +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 == "" { + 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.", panel.Title) + } + } + + 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 +180,44 @@ 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 data.entersComponentsV2(database.MultiPanel{}) { + tier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if !componentsV2BuilderEnabled(c, userId, guildId, tier) { + c.JSON(403, utils.ErrorStr(componentsV2UnavailableMessage)) + return + } + } + + if data.UsesComponentsV2 { + paid, err := hasPaidPremium(c, guildId, botContext) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if !paid { + c.JSON(402, utils.ErrorStr(componentsV2PremiumMessage)) + return + } + + 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 +230,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 +273,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 +294,34 @@ func MultiPanelCreate(c *gin.Context) { return } - dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct() + // Any return before the row exists would leave the message in the channel with nothing + // behind it. + 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 +329,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 +374,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()) @@ -282,7 +438,13 @@ func (d *multiPanelCreateData) validatePanels(guildId uint64) (panels []database 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/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..32e003b0 100644 --- a/backend/app/http/endpoints/api/panel/multipanelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/multipanelmessagedata.go @@ -3,16 +3,18 @@ package api import ( "context" "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" ) type multiPanelMessageData struct { @@ -24,6 +26,9 @@ type multiPanelMessageData struct { SelectMenuPlaceholder *string Embed *embed.Embed + + UsesComponentsV2 bool + Components []component.Component } func (d *multiPanelMessageData) applyFooter() { @@ -43,21 +48,38 @@ 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 { + // A corrupt tree renders as an empty one. Falling back to Classic would send an + // edit without the V2 flag to a message that has it, which Discord refuses. + data.UsesComponentsV2 = true + + components, err := unmarshalComponents(panel.Components) + if err != nil { + log.Logger.Error("Failed to unmarshal multi-panel components; rendering an empty tree", 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 +120,263 @@ 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() +// The dashboard submits a blank description as "", and Discord rejects a select option whose +// description is present but empty. +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))} + } - row := component.BuildActionRow(buttons[lb:ub]...) - rows = append(rows, row) + var rows []component.Component + for start := 0; start < len(panels); start += maxActionRowChildren { + end := min(start+maxActionRowChildren, len(panels)) + + 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 +} + +// Anything that cannot be resolved is dropped, so a "panel:" marker can never reach Discord. +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 != "" +} + +// A parent left unable to render without a rejected leaf is dropped too: an action row or +// container with no children, and a section with no text or no accessory. +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 + } + + out = append(out, c) + } + + return out +} + +func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.PanelWithCustomization) (uint64, error) { + tree, system := d.buildComponents(panels) + + var data rest.CreateMessageData + if d.UsesComponentsV2 { + 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 +} - components = rows +func (d *multiPanelMessageData) buildEditData(components []component.Component) rest.EditMessageData { + if d.UsesComponentsV2 { + return v2EditData(components) } - data := rest.EditMessageData{ + d.applyFooter() + return rest.EditMessageData{ Embeds: []*embed.Embed{d.Embed}, Components: components, } +} + +// IS_COMPONENTS_V2 can be set but never removed, and an edit that omits flags is validated as +// a classic message. Embeds must be empty on a V2 message, and without allowed_mentions +// Discord re-parses mentions with its default allowances. +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/multipanelupdate.go b/backend/app/http/endpoints/api/panel/multipanelupdate.go index 3b9932db..8dbeca27 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" @@ -15,9 +14,9 @@ import ( "github.com/go-playground/validator/v10" "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/rpc" "github.com/ticketsbot-cloud/dashboard/backend/utils" "golang.org/x/sync/errgroup" ) @@ -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,49 @@ 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 data.entersComponentsV2(multiPanel) { + tier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if !componentsV2BuilderEnabled(c, userId, guildId, tier) { + c.JSON(403, utils.ErrorStr(componentsV2UnavailableMessage)) + return + } + } + + if data.UsesComponentsV2 { + paid, err := hasPaidPremium(c, guildId, botContext) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if !paid { + c.JSON(402, utils.ErrorStr(componentsV2PremiumMessage)) + return + } + + 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 +119,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 +154,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,11 +177,19 @@ func MultiPanelUpdate(c *gin.Context) { } } + if data.UsesComponentsV2 { + data.Components = normaliseComponentTree(data.Components) + } + messageData := data.IntoMessageData(footer) var messageId uint64 + // Discord does not allow unsetting IS_COMPONENTS_V2 via edit, so a downgrade back to + // Classic must resend rather than edit in place, same as a channel change. + downgradingFromV2 := multiPanel.UsesComponentsV2 && !data.UsesComponentsV2 + // Check if channel changed - if multiPanel.ChannelId != data.ChannelId { + if multiPanel.ChannelId != data.ChannelId || downgradingFromV2 { ctx, cancel := app.DefaultContext() defer cancel() @@ -229,7 +260,17 @@ func MultiPanelUpdate(c *gin.Context) { } // 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,10 +278,10 @@ 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 { @@ -248,6 +289,16 @@ func MultiPanelUpdate(c *gin.Context) { return } + if err := dbclient.Client.MultiPanels.UpdateComponentsV2(c, multiPanel.Id, updated.UsesComponentsV2, updated.Components); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + return + } + + if err := dbclient.Client.MultiPanels.SetName(c, multiPanel.Id, updated.Name); err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update multi-panel")) + return + } + // 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 f7ec22a8..6ff32bd2 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"` @@ -78,22 +79,29 @@ type panelBody struct { SupportCanView bool `json:"support_can_view"` SupportCanType bool `json:"support_can_type"` AutoClose PanelAutoCloseBody `json:"auto_close"` + + 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, } } @@ -123,13 +131,32 @@ func CreatePanel(c *gin.Context) { data.MessageId = 0 - // Check panel quota + // Voting premium unlocks fields but not quota or Components V2, so this lookup excludes it. 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 } + if data.entersComponentsV2(database.Panel{}) { + flagTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to verify premium status")) + return + } + + if !componentsV2BuilderEnabled(c, userId, guildId, flagTier) { + c.JSON(403, utils.ErrorStr(componentsV2UnavailableMessage)) + return + } + } + + hasPaid := quotaTier > premium.None + if (data.MessageUsesComponentsV2 || data.WelcomeMessageUsesComponentsV2) && !hasPaid { + c.JSON(402, utils.ErrorStr(componentsV2PremiumMessage)) + return + } + if quotaTier == premium.None { panels, err := dbclient.Client.Panel.GetByGuild(c, guildId) if err != nil { @@ -146,6 +173,12 @@ func CreatePanel(c *gin.Context) { // Apply defaults ApplyPanelDefaults(&data) + // Validation skips these in V2 mode because the editor hides them, but they are still stored. + 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 { @@ -183,12 +216,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 { @@ -206,6 +240,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 @@ -231,6 +267,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 { @@ -248,6 +291,15 @@ func CreatePanel(c *gin.Context) { return } + // Any return before the panel row exists would leave the message in the channel with + // nothing behind it. + stored := false + defer func() { + if !stored { + discardMessage(botContext, data.ChannelId, msgId) + } + }() + var emojiId *uint64 var emojiName *string var emojiAnimated bool @@ -263,10 +315,14 @@ func CreatePanel(c *gin.Context) { } } - // Store welcome message embed first + // The classic welcome embed is kept alongside a Components V2 welcome message so + // switching back to Classic does not lose it. dropHiddenClassicEmbed has already + // discarded an unusable one. + 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) @@ -278,6 +334,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 @@ -288,6 +362,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), @@ -326,6 +401,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{ @@ -364,6 +444,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..6b613390 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 } diff --git a/backend/app/http/endpoints/api/panel/panellist.go b/backend/app/http/endpoints/api/panel/panellist.go index 4aeadfb5..58a7a054 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"` @@ -110,9 +111,17 @@ func ListPanels(c *gin.Context) { wrapped := make([]panelResponse, len(panels)) for i, p := range panels { hasSH := supportHoursSet[p.PanelId] + + // Title was the list label before Name existed and still is for a panel without one. + displayName := p.Title + if p.Name != nil && *p.Name != "" { + displayName = *p.Name + } + 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..1988b1ec 100644 --- a/backend/app/http/endpoints/api/panel/panelmessagedata.go +++ b/backend/app/http/endpoints/api/panel/panelmessagedata.go @@ -1,18 +1,24 @@ package api import ( + "context" + "encoding/json" "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/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 +32,46 @@ 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 +} + +// componentsToStore keeps the stored tree through a Classic save, so switching back to +// Components V2 does not lose it, the same way the classic fields survive a V2 save. +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,24 @@ func panelIntoMessageData(panel database.Panel, showBranding bool) panelMessageD ButtonDisabled: panel.Disabled, ShowBranding: showBranding, } + + if panel.MessageUsesComponentsV2 { + // A corrupt tree renders as an empty one. Falling back to Classic would send an + // edit without the V2 flag to a message that has it, which Discord refuses. + data.UsesComponentsV2 = true + + components, err := unmarshalComponents(panel.MessageComponents) + if err != nil { + log.Logger.Error("Failed to unmarshal panel message components; rendering an empty tree", 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 +138,38 @@ 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) systemButton() component.Component { + return component.BuildActionRow(component.BuildButton(component.Button{ + Label: p.ButtonLabel, + CustomId: p.CustomId, + Style: p.ButtonStyle, + Emoji: p.Emoji, + Url: nil, + Disabled: p.ButtonDisabled, + })) +} + +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: append(append([]component.Component{}, p.Components...), p.systemButton()), + } + } 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 +178,33 @@ 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) - - if p.ImageUrl != nil { - e.SetImage(*p.ImageUrl) +func (p *panelMessageData) buildEditData() rest.EditMessageData { + if p.UsesComponentsV2 { + return v2EditData(append(append([]component.Component{}, p.Components...), p.systemButton())) } - if p.ThumbnailUrl != nil { - e.SetThumbnail(*p.ThumbnailUrl) - } - - if p.ShowBranding { - e.SetFooter(fmt.Sprintf("Powered by %s", config.Conf.Bot.PoweredBy), config.Conf.Bot.IconUrl) - } - - 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 rest.EditMessageData{ + Embeds: []*embed.Embed{p.buildEmbed()}, + Components: []component.Component{p.systemButton()}, } +} +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 } + +// 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() + + 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..91c9e8c7 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/panelname_test.go @@ -0,0 +1,43 @@ +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: "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 4c768cce..3c2a19da 100644 --- a/backend/app/http/endpoints/api/panel/panelupdate.go +++ b/backend/app/http/endpoints/api/panel/panelupdate.go @@ -51,7 +51,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 } @@ -75,6 +76,11 @@ func UpdatePanel(c *gin.Context) { return } + if err := data.keepStoredWhenAbsent(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")) @@ -86,15 +92,36 @@ func UpdatePanel(c *gin.Context) { return } - // Apply defaults - ApplyPanelDefaults(&data) - 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 } + if data.entersComponentsV2(existing) && !componentsV2BuilderEnabled(c, userId, guildId, premiumTier) { + c.JSON(403, utils.ErrorStr(componentsV2UnavailableMessage)) + return + } + + hasPaid := false + if data.MessageUsesComponentsV2 || data.WelcomeMessageUsesComponentsV2 { + hasPaid, err = hasPaidPremium(c, guildId, botContext) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) + return + } + + if !hasPaid { + c.JSON(402, utils.ErrorStr(componentsV2PremiumMessage)) + return + } + } + + data.retainClassicFields(existing, keys["colour"]) + + // Apply defaults + ApplyPanelDefaults(&data) + footer, err := footerPolicyForGuild(c, guildId, botContext) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to update panel")) @@ -121,12 +148,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 { @@ -144,6 +172,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 @@ -172,11 +202,22 @@ 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 + // Discord does not allow unsetting IS_COMPONENTS_V2 via edit, so a downgrade back to + // Classic must resend rather than edit in place, same as a channel change. + downgradingFromV2 := existing.MessageUsesComponentsV2 && !data.MessageUsesComponentsV2 + // Check if channel changed - if existing.ChannelId != data.ChannelId { + if existing.ChannelId != data.ChannelId || downgradingFromV2 { _ = rest.DeleteMessage(c, botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId) newMessageId, err = messageData.send(botContext) if err != nil { @@ -230,42 +271,56 @@ 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 + // The classic welcome embed is kept alongside a Components V2 welcome message so + // switching back to Classic does not lose it. In V2 mode an absent or unusable embed + // (already dropped by dropHiddenClassicEmbed) leaves the stored one alone. + 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 @@ -277,6 +332,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), @@ -315,6 +371,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 @@ -347,6 +408,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 } diff --git a/backend/app/http/endpoints/api/panel/permcheck.go b/backend/app/http/endpoints/api/panel/permcheck.go index e1a102d7..4e8f6956 100644 --- a/backend/app/http/endpoints/api/panel/permcheck.go +++ b/backend/app/http/endpoints/api/panel/permcheck.go @@ -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..35a822e1 --- /dev/null +++ b/backend/app/http/endpoints/api/panel/premiumgate.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + + "github.com/TicketsBot-cloud/common/premium" + "github.com/TicketsBot-cloud/database" + "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 without premium." + componentsV2UnavailableMessage = "Components v2 messages aren't available for this server yet. Switch them back to Classic to save." +) + +// hasPaidPremium excludes voting premium, as custom footers do: Components V2 is a paid +// feature, not something votes unlock. +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 +} + +// Same attributes as GetGuildFeatureFlags, voting premium included, so the verdict matches what +// the browser was told. +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) +} + +// Only switching a message to Components V2 is gated, so turning the flag off never locks a +// stored V2 message. +func (p *panelBody) entersComponentsV2(stored database.Panel) bool { + return (p.MessageUsesComponentsV2 && !stored.MessageUsesComponentsV2) || + (p.WelcomeMessageUsesComponentsV2 && !stored.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..51e0021b --- /dev/null +++ b/backend/app/http/endpoints/api/panel/premiumgate_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "testing" + + "github.com/TicketsBot-cloud/database" + "github.com/stretchr/testify/require" +) + +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)) + }) + } +} diff --git a/backend/app/http/endpoints/api/panel/supporthours.go b/backend/app/http/endpoints/api/panel/supporthours.go index 7b485df6..c29640e2 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 } @@ -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 12ada6e1..807dbd26 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" @@ -47,13 +46,19 @@ 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 excludes voting premium, which unlocks fields but not Components V2. + HasPaidPremium bool + BotContext *botcontext.BotContext + Channels []channel.Channel + Roles []guild.Role } func ValidatePanelBody(validationContext PanelValidationContext) error { @@ -65,6 +70,7 @@ func ValidatePanelBody(validationContext PanelValidationContext) error { func panelValidators() []validation.Validator[PanelValidationContext] { return []validation.Validator[PanelValidationContext]{ + validateName, validateTitle, validateContent, validateChannelId, @@ -91,6 +97,44 @@ 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) + } + + 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 { + if name := ctx.Data.trimmedName(); name != nil && utf8.RuneCountInString(*name) > panelNameMaxLength { + return validation.NewInvalidInputErrorf("Panel name must be %d characters or fewer", panelNameMaxLength) + } + + return nil } } @@ -213,9 +257,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") } @@ -224,16 +272,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") } @@ -434,6 +492,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) } @@ -476,15 +538,74 @@ 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 validateEmbed(e *types.CustomEmbed) error { if e == nil { 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) +} + +// The user cannot see this embed in V2 mode, so an unusable one is dropped (nil, meaning +// "leave what is stored") instead of failing the save. +func classicEmbedForV2(e *types.CustomEmbed) *types.CustomEmbed { + if e == nil || !embedHasContent(e) || validateEmbedContent(e) != nil || validate.Struct(e) != nil { + return nil + } + + return e +} + +// dropHiddenClassicEmbed must run before validate.Struct(p), which would otherwise reject +// the welcome embed's struct tags for an embed the user cannot see. +func (p *panelBody) dropHiddenClassicEmbed() { + if p.WelcomeMessageUsesComponentsV2 { + p.WelcomeMessage = classicEmbedForV2(p.WelcomeMessage) + } +} + +// The editor hides these fields in V2 mode, so a missing or invalid value keeps the stored one +// for a switch back to Classic. Only an omitted colour counts as missing, since null means no +// colour. It must run before ApplyPanelDefaults. +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 +} + +// validClassicUrlOrNil is for a V2 create, where there is nothing stored to fall back to. +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()) } @@ -636,6 +757,62 @@ 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) + } + + _, err := validateComponentTree(ctx.Data.MessageComponents, treeRules{reserved: panelMessageReserved}) + return err + } +} + +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 + } +} + +// The worker appends the form answers and the visible buttons at ticket open. +func welcomeMessageReserved(data panelBody) int { + visible := 0 + if !data.HideCloseButton { + visible++ + } + if !data.HideCloseWithReasonButton { + visible++ + } + // Claim is not offered in thread mode. + 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..2e161c9c --- /dev/null +++ b/backend/app/http/endpoints/api/panel/validation_test.go @@ -0,0 +1,534 @@ +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: "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 TestValidateWelcomeMessageComponents(t *testing.T) { + // Defaults: all three buttons visible, so 5 + 3 + 1 = 9 reserved. + tests := []struct { + name string + data panelBody + paid bool + wantErr string + }{ + {name: "classic mode skips everything", data: panelBody{}}, + {name: "classic mode without premium ignores a tree that would fail", 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: "31 components with all buttons visible", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(31)}, paid: true}, + {name: "32 components with all buttons visible", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(32)}, paid: true, wantErr: "Too many components"}, + {name: "35 components with every button hidden", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(35), HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, paid: true}, + {name: "36 components with every button hidden", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(36), 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(32), UseThreads: true}, paid: true}, + {name: "thread mode with one more", data: panelBody{WelcomeMessageUsesComponentsV2: true, WelcomeMessageComponents: texts(33), 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{}, 9}, + {"close only", panelBody{HideCloseWithReasonButton: true, HideClaimButton: true}, 7}, + {"none", panelBody{HideCloseButton: true, HideCloseWithReasonButton: true, HideClaimButton: true}, 5}, + {"thread mode drops claim", panelBody{UseThreads: true}, 8}, + {"thread mode with only claim visible", panelBody{UseThreads: true, HideCloseButton: true, HideCloseWithReasonButton: true}, 5}, + } + + 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("the welcome embed check follows the welcome flag, not the message 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 fails its struct tags", &types.CustomEmbed{Title: strPtr(strings.Repeat("a", 257))}, false}, + {"embed field with an empty name fails its struct tags", &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}, + } + + 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)}}, + + // Button mode appends ceil(unplaced / 5) rows plus the buttons. + {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 frees a slot but keeps the placed 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)}}, + + // Dropdown mode appends row plus select, unless a dropdown is placed. + {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: "nothing to list"}, + {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}, + {"a malformed marker is left for validation to reject", 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("an empty custom label falls back to the button label", 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 is still rejected", body: panelBody{WelcomeMessage: invalid()}, wantKept: true, wantErr: true}, + {name: "the message flag alone does not drop 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 keeps the stored hidden fields but not the title", + 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 and nothing valid 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 and no colour supplied leaves the 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/getticket.go b/backend/app/http/endpoints/api/ticket/getticket.go index 0e424b90..1cd3d77e 100644 --- a/backend/app/http/endpoints/api/ticket/getticket.go +++ b/backend/app/http/endpoints/api/ticket/getticket.go @@ -93,7 +93,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 } } diff --git a/backend/app/http/endpoints/api/ticket/gettickets.go b/backend/app/http/endpoints/api/ticket/gettickets.go index f58c6f94..77ad7532 100644 --- a/backend/app/http/endpoints/api/ticket/gettickets.go +++ b/backend/app/http/endpoints/api/ticket/gettickets.go @@ -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 @@ -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/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/middleware/limitjsonbody.go b/backend/app/http/middleware/limitjsonbody.go new file mode 100644 index 00000000..41faeb2a --- /dev/null +++ b/backend/app/http/middleware/limitjsonbody.go @@ -0,0 +1,87 @@ +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") + +// gdl's Component.UnmarshalJSON re-parses every subtree, so its cost is quadratic in nesting +// depth; an over-deep body must be rejected before it ever reaches that code. +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.")) +} + +// checkJSONDepth streams tokens rather than unmarshalling, and stops at the first token that +// exceeds maxDepth, so a hostile body costs at most maxDepth+1 levels of work. +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..12960f1a --- /dev/null +++ b/backend/app/http/middleware/limitjsonbody_test.go @@ -0,0 +1,94 @@ +package middleware + +import ( + "bytes" + "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) +} 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 65201fc4..7ed266b9 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/frontend/src/components/PanelPreview.tsx b/frontend/src/components/PanelPreview.tsx index 1a753e02..29fa46ce 100644 --- a/frontend/src/components/PanelPreview.tsx +++ b/frontend/src/components/PanelPreview.tsx @@ -1,13 +1,18 @@ -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"; import { isSafeUrl } from "@/lib/url"; import { previewAvatarUrl } from "@/lib/embed-avatar"; import { embedBorderColour } from "@/lib/colour"; +import { toPreviewComponents, visibleWelcomeButtons, type V2Component } from "@/lib/component-tree"; + +const EMPTY_ENTITIES = { users: {}, channels: {}, roles: {} }; function resolveEmoteName(emote: Panel["emote"] | undefined): string { if (typeof emote === "string") return emote; @@ -27,70 +32,106 @@ interface PanelPreviewProps { buttons?: Panel[]; }; brandingFooter?: boolean; + messageMode?: "classic" | "components_v2"; + componentTree?: V2Component[]; + /** Every panel in the multi-panel, so placed panels in the tree resolve to real buttons. */ + panelsForPreview?: Panel[]; } -const PanelPreview: FC = ({ type, data, brandingFooter }) => { +const PanelPreview: FC = ({ + type, + data, + brandingFooter, + messageMode = "classic", + componentTree, + panelsForPreview, +}) => { const { panel, buttons } = data; + 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/component-builder/AddBlockMenu.tsx b/frontend/src/components/component-builder/AddBlockMenu.tsx new file mode 100644 index 00000000..d9cd0741 --- /dev/null +++ b/frontend/src/components/component-builder/AddBlockMenu.tsx @@ -0,0 +1,219 @@ +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; + /** Lets a delete that empties the list move focus to this trigger. */ + 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 tabbing out of it would land somewhere + // unrelated to the trigger. + 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, not a + // deliberate move away. + 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" && availablePanels === 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..e6055d74 --- /dev/null +++ b/frontend/src/components/component-builder/BlockList.tsx @@ -0,0 +1,228 @@ +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[]; + /** Every panel in the multi-panel, so a block can look up the panel it already holds. */ + allPanels?: PickerPanel[]; + selectMenuModeOn?: boolean; + saveAttempted?: boolean; +} + +const BlockList: FC = ({ + blocks, + tree, + onTreeChange, + allowAvatarPlaceholder, + nested, + containerId, + containerLabel, + onAnnounce, + disabled, + budgetMax, + availablePanels, + allPanels, + selectMenuModeOn, + saveAttempted, +}) => { + 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} + > + {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}` : ""}`); + }} + /> + )} +
+ ); +}; + +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..0c84edb9 --- /dev/null +++ b/frontend/src/components/component-builder/BlockShell.tsx @@ -0,0 +1,169 @@ +import { 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; + children: ReactNode; +} + +const BlockShell: FC = ({ + blockId, + label, + containerLabel, + icon, + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + moveIntoContainerTargets, + onMoveIntoContainer, + canMoveOutOfContainer, + onMoveOutOfContainer, + onDelete, + nested, + disabled, + children, +}) => { + 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; + // A deleted container's id must not stay chosen: moving into it would drop the block. + const chosenTarget = moveIntoContainerTargets?.some((target) => target.id === moveTarget) + ? moveTarget + : ""; + + // Reordering moves this block's DOM nodes, which drops focus, and the pressed arrow becomes + // disabled at either end of the list. + 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 && ( +
+ + +
+ )} + + + +
+
+ {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..e6c94817 --- /dev/null +++ b/frontend/src/components/component-builder/BuildPreviewTabs.tsx @@ -0,0 +1,67 @@ +import { useEffect, useId, useRef, useState, 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; +} + +/** Renders three siblings so a parent `md:grid-cols-2` lays the panes out together from `md` up. */ +const BuildPreviewTabs: FC = ({ build, preview, className = "" }) => { + const [activeTab, setActiveTab] = useState<"build" | "preview">("build"); + const uid = useId(); + const buildKey = `${uid}-build`; + const previewKey = `${uid}-preview`; + const buildPanelRef = useRef(null); + + // A failed save reveals the first problem field. Below md the Build pane is display:none while + // Preview is active, so switch to it first or the field cannot take focus. flushSync because the + // caller focuses the field on the next frame. + 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..0a078113 --- /dev/null +++ b/frontend/src/components/component-builder/ButtonRowEditor.tsx @@ -0,0 +1,211 @@ +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_ROW_BUTTONS, + MAX_URL_LENGTH, + linkLabelError, + linkUrlError, + type V2ButtonRow, + type V2ButtonRowButton, +} from "@/lib/component-tree"; +import PanelButtonTag from "./PanelButtonTag"; +import { 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 ? ( +
+ 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..3290ddd9 --- /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 = () => ( + + 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..58560c3b --- /dev/null +++ b/frontend/src/components/component-builder/SectionEditor.tsx @@ -0,0 +1,312 @@ +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_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 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 { 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) => ( +
+