diff --git a/backend/app/http/endpoints/api/gallery/galleryimport.go b/backend/app/http/endpoints/api/gallery/galleryimport.go index 9231f02b..341232a1 100644 --- a/backend/app/http/endpoints/api/gallery/galleryimport.go +++ b/backend/app/http/endpoints/api/gallery/galleryimport.go @@ -60,6 +60,23 @@ func ImportHandler(ctx *gin.Context) { return } + // The bot token can post anywhere the bot is, so both IDs must be proven to belong to this guild + // before anything is sent or stored, otherwise a panel can be planted in another server. + if body.ChannelId != nil || body.CategoryId != nil { + chCtx, cancel := app.DefaultContext() + channels, err := botContext.GetGuildChannels(chCtx, guildId) + cancel() + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch guild channels from Discord")) + return + } + + if msg := validateImportTargets(channels, body.ChannelId, body.CategoryId); msg != "" { + ctx.JSON(http.StatusBadRequest, utils.ErrorStr("%s", msg)) + return + } + } + // Check panel quota premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, false, botContext.Token, botContext.RateLimiter) if err != nil { diff --git a/backend/app/http/endpoints/api/gallery/importvalidation.go b/backend/app/http/endpoints/api/gallery/importvalidation.go index b0a51d37..fd0bcb84 100644 --- a/backend/app/http/endpoints/api/gallery/importvalidation.go +++ b/backend/app/http/endpoints/api/gallery/importvalidation.go @@ -4,9 +4,47 @@ import ( "errors" "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/channel" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" ) +// validateImportTargets returns a user-facing error message, or "" if both targets are valid. +func validateImportTargets(channels []channel.Channel, channelId, categoryId *uint64) string { + find := func(id uint64) (channel.Channel, bool) { + for _, ch := range channels { + if ch.Id == id { + return ch, true + } + } + return channel.Channel{}, false + } + + if channelId != nil { + ch, ok := find(*channelId) + if !ok { + return "Panel channel not found" + } + if ch.IsObfuscated() { + return "Panel channel is not visible to the bot - grant it the View Channel permission" + } + if ch.Type != channel.ChannelTypeGuildText && ch.Type != channel.ChannelTypeGuildNews { + return "Panel channel must be a text or announcement channel" + } + } + + if categoryId != nil { + ch, ok := find(*categoryId) + if !ok || ch.Type != channel.ChannelTypeGuildCategory { + return "Invalid ticket category" + } + if ch.IsObfuscated() { + return "Ticket category is not visible to the bot - grant it the View Channel permission" + } + } + + return "" +} + // Snapshots may predate URL validation, and the importer cannot edit someone else's listing. func validateListingUrls(e *database.CustomEmbed, panelUrls ...*string) error { for _, url := range panelUrls { diff --git a/backend/app/http/endpoints/api/gallery/importvalidation_test.go b/backend/app/http/endpoints/api/gallery/importvalidation_test.go new file mode 100644 index 00000000..7719ddf0 --- /dev/null +++ b/backend/app/http/endpoints/api/gallery/importvalidation_test.go @@ -0,0 +1,59 @@ +package gallery + +import ( + "testing" + + "github.com/TicketsBot-cloud/gdl/objects/channel" + "github.com/stretchr/testify/require" +) + +func TestValidateImportTargets(t *testing.T) { + const ( + textId uint64 = 1 + newsId uint64 = 2 + voiceId uint64 = 3 + categoryId uint64 = 4 + foreignId uint64 = 999 + ) + + channels := []channel.Channel{ + {Id: textId, Type: channel.ChannelTypeGuildText}, + {Id: newsId, Type: channel.ChannelTypeGuildNews}, + {Id: voiceId, Type: channel.ChannelTypeGuildVoice}, + {Id: categoryId, Type: channel.ChannelTypeGuildCategory}, + } + + ptr := func(v uint64) *uint64 { return &v } + + tests := []struct { + name string + channels []channel.Channel + channel *uint64 + category *uint64 + wantErr bool + }{ + {name: "neither supplied", channels: channels}, + {name: "text channel", channels: channels, channel: ptr(textId)}, + {name: "announcement channel", channels: channels, channel: ptr(newsId)}, + {name: "channel and category", channels: channels, channel: ptr(textId), category: ptr(categoryId)}, + {name: "channel from another guild", channels: channels, channel: ptr(foreignId), wantErr: true}, + {name: "category from another guild", channels: channels, category: ptr(foreignId), wantErr: true}, + {name: "valid channel, foreign category", channels: channels, channel: ptr(textId), category: ptr(foreignId), wantErr: true}, + {name: "voice channel as panel channel", channels: channels, channel: ptr(voiceId), wantErr: true}, + {name: "category as panel channel", channels: channels, channel: ptr(categoryId), wantErr: true}, + {name: "text channel as category", channels: channels, category: ptr(textId), wantErr: true}, + {name: "zero channel id", channels: channels, channel: ptr(0), wantErr: true}, + {name: "empty guild channel list", channels: nil, channel: ptr(textId), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := validateImportTargets(tt.channels, tt.channel, tt.category) + if tt.wantErr { + require.NotEmpty(t, msg) + } else { + require.Empty(t, msg) + } + }) + } +}