diff --git a/app/http/endpoints/api/panel/panelcreate.go b/app/http/endpoints/api/panel/panelcreate.go index 81803c0..a0f5bef 100644 --- a/app/http/endpoints/api/panel/panelcreate.go +++ b/app/http/endpoints/api/panel/panelcreate.go @@ -2,10 +2,10 @@ package api import ( "errors" + "github.com/TicketsBot/GoPanel/app/http/validation" "github.com/TicketsBot/GoPanel/botcontext" dbclient "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/rpc" - "github.com/TicketsBot/GoPanel/rpc/cache" "github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils/types" "github.com/TicketsBot/common/collections" @@ -13,38 +13,34 @@ import ( "github.com/TicketsBot/database" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" - "github.com/rxdn/gdl/objects/channel" "github.com/rxdn/gdl/objects/guild/emoji" "github.com/rxdn/gdl/objects/interaction/component" "github.com/rxdn/gdl/rest/request" - "regexp" "strconv" - "strings" ) const freePanelLimit = 3 -var placeholderPattern = regexp.MustCompile(`%(\w+)%`) - type panelBody struct { - ChannelId uint64 `json:"channel_id,string"` - MessageId uint64 `json:"message_id,string"` - Title string `json:"title"` - Content string `json:"content"` - Colour uint32 `json:"colour"` - CategoryId uint64 `json:"category_id,string"` - Emoji types.Emoji `json:"emote"` - WelcomeMessage *types.CustomEmbed `json:"welcome_message" validate:"omitempty,dive"` - Mentions []string `json:"mentions"` - WithDefaultTeam bool `json:"default_team"` - Teams []int `json:"teams"` - ImageUrl *string `json:"image_url,omitempty"` - ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - ButtonStyle component.ButtonStyle `json:"button_style,string"` - ButtonLabel string `json:"button_label"` - FormId *int `json:"form_id"` - NamingScheme *string `json:"naming_scheme"` - Disabled bool `json:"disabled"` + ChannelId uint64 `json:"channel_id,string"` + MessageId uint64 `json:"message_id,string"` + Title string `json:"title"` + Content string `json:"content"` + Colour uint32 `json:"colour"` + CategoryId uint64 `json:"category_id,string"` + Emoji types.Emoji `json:"emote"` + WelcomeMessage *types.CustomEmbed `json:"welcome_message" validate:"omitempty,dive"` + Mentions []string `json:"mentions"` + WithDefaultTeam bool `json:"default_team"` + Teams []int `json:"teams"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle component.ButtonStyle `json:"button_style,string"` + ButtonLabel string `json:"button_label"` + FormId *int `json:"form_id"` + NamingScheme *string `json:"naming_scheme"` + Disabled bool `json:"disabled"` + ExitSurveyFormId *int `json:"exit_survey_form_id"` } func (p *panelBody) IntoPanelMessageData(customId string, isPremium bool) panelMessageData { @@ -64,6 +60,8 @@ func (p *panelBody) IntoPanelMessageData(customId string, isPremium bool) panelM } } +var validate = validator.New() + func CreatePanel(ctx *gin.Context) { guildId := ctx.Keys["guildid"].(uint64) @@ -102,7 +100,45 @@ func CreatePanel(ctx *gin.Context) { } } - if !data.doValidations(ctx, guildId) { + // Apply defaults + ApplyPanelDefaults(&data) + + channels, err := botContext.GetGuildChannels(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Do custom validation + validationContext := PanelValidationContext{ + Data: data, + GuildId: guildId, + IsPremium: premiumTier > premium.None, + BotContext: botContext, + Channels: channels, + } + + if err := ValidatePanelBody(validationContext); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + ctx.JSON(400, utils.ErrorStr(validationError.Error())) + } else { + ctx.JSON(500, utils.ErrorJson(err)) + } + + return + } + + // Do tag validation + if err := validate.Struct(data); err != nil { + validationErrors, ok := err.(validator.ValidationErrors) + if !ok { + ctx.JSON(500, utils.ErrorStr("An error occurred while validating the panel")) + return + } + + formatted := "Your input contained the following errors:\n" + utils.FormatValidationErrors(validationErrors) + ctx.JSON(400, utils.ErrorStr(formatted)) return } @@ -170,7 +206,9 @@ func CreatePanel(ctx *gin.Context) { ButtonLabel: data.ButtonLabel, FormId: data.FormId, NamingScheme: data.NamingScheme, + ForceDisabled: false, Disabled: data.Disabled, + ExitSurveyFormId: data.ExitSurveyFormId, } panelId, err := dbclient.Client.Panel.Create(panel) @@ -227,312 +265,11 @@ func CreatePanel(ctx *gin.Context) { }) } -var urlRegex = regexp.MustCompile(`^https?://([-a-zA-Z0-9@:%._+~#=]{1,256})\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$`) -var validate = validator.New() - -func (p *panelBody) doValidations(ctx *gin.Context, guildId uint64) bool { - if err := validate.Struct(p); err != nil { - validationErrors, ok := err.(validator.ValidationErrors) - if !ok { - ctx.JSON(500, utils.ErrorStr("An error occurred while validating the panel")) - return false - } - - formatted := "Your input contained the following errors:\n" + utils.FormatValidationErrors(validationErrors) - ctx.JSON(400, utils.ErrorStr(formatted)) - return false - } - - botContext, err := botcontext.ContextForGuild(guildId) - if err != nil { - return false // TODO: Log error - } - - if !p.verifyTitle() { - ctx.JSON(400, gin.H{ - "success": false, - "error": "Panel titles must be between 1 - 80 characters in length", - }) - return false - } - - if !p.verifyContent() { - ctx.JSON(400, gin.H{ - "success": false, - "error": "Panel content must be between 1 - 4096 characters in length", - }) - return false - } - - channels := cache.Instance.GetGuildChannels(guildId) - - if !p.verifyChannel(channels) { - ctx.JSON(400, utils.ErrorStr("Invalid channel")) - return false - } - - if !p.verifyCategory(channels) { - ctx.JSON(400, utils.ErrorStr("Invalid channel category")) - return false - } - - if !p.verifyEmoji(botContext, guildId) { - ctx.JSON(400, utils.ErrorStr("Invalid emoji")) - return false - } - - if !p.verifyImageUrl() || !p.verifyThumbnailUrl() { - ctx.JSON(400, gin.H{ - "success": false, - "error": "Image URL must be between 1 - 255 characters and a valid URL", - }) - return false - } - - if !p.verifyButtonStyle() { - ctx.JSON(400, gin.H{ - "success": false, - "error": "Invalid button style", - }) - return false - } - - if !p.validateWelcomeMessage() { - ctx.JSON(400, utils.ErrorStr("The welcome message you provided has no content")) - return false - } - - if !p.verifyButtonLabel() { - ctx.JSON(400, utils.ErrorStr("Button labels cannot be longer than 80 characters")) - return false - } - - { - valid, err := p.verifyTeams(guildId) - if err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return false - } - - if !valid { - ctx.JSON(400, utils.ErrorStr("Invalid teams provided")) - return false - } - } - - { - ok, err := p.verifyFormId(guildId) - if err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return false - } - - if !ok { - ctx.JSON(400, utils.ErrorStr("Guild ID for form does not match")) - return false - } - } - - if !p.verifyNamingScheme() { - ctx.JSON(400, utils.ErrorStr("Invalid naming scheme: ensure that the naming scheme is less than 100 characters and the placeholders you have used are valid")) - return false - } - - return true -} - -func (p *panelBody) verifyTitle() bool { - if len(p.Title) > 80 { - return false - } else if len(p.Title) == 0 { // Fill default - p.Title = "Open a ticket!" - } - - return true -} - -func (p *panelBody) verifyContent() bool { - if len(p.Content) > 4096 { - return false - } else if len(p.Content) == 0 { // Fill default - p.Content = "By clicking the button, a ticket will be opened for you." - } - - return true -} - // Data must be validated before calling this function func (p *panelBody) getEmoji() *emoji.Emoji { return p.Emoji.IntoGdl() } -func (p *panelBody) verifyChannel(channels []channel.Channel) bool { - var valid bool - for _, ch := range channels { - if ch.Id == p.ChannelId && (ch.Type == channel.ChannelTypeGuildText || ch.Type == channel.ChannelTypeGuildNews) { - valid = true - break - } - } - - return valid -} - -func (p *panelBody) verifyCategory(channels []channel.Channel) bool { - var valid bool - for _, ch := range channels { - if ch.Id == p.CategoryId && ch.Type == channel.ChannelTypeGuildCategory { - valid = true - break - } - } - - return valid -} - -func (p *panelBody) verifyEmoji(ctx botcontext.BotContext, guildId uint64) bool { - if p.Emoji.IsCustomEmoji { - if p.Emoji.Id == nil { - return false - } - - emoji, err := ctx.GetGuildEmoji(guildId, *p.Emoji.Id) - if err != nil { // TODO: Log - return false - } - - if emoji.Id.Value == 0 { - return false - } - - if emoji.Name != p.Emoji.Name { - return false - } - - return true - } else { - if len(p.Emoji.Name) == 0 { - return true - } - - // Convert from :emoji: to unicode if we need to - name := strings.TrimSpace(p.Emoji.Name) - name = strings.Replace(name, ":", "", -1) - - unicode, ok := utils.GetEmoji(name) - if !ok { - return false - } - - p.Emoji.Name = unicode - return true - } -} - -func (p *panelBody) verifyImageUrl() bool { - if p.ImageUrl != nil && len(*p.ImageUrl) == 0 { - p.ImageUrl = nil - } - - return p.ImageUrl == nil || (len(*p.ImageUrl) <= 255 && urlRegex.MatchString(*p.ImageUrl)) -} - -func (p *panelBody) verifyThumbnailUrl() bool { - if p.ThumbnailUrl != nil && len(*p.ThumbnailUrl) == 0 { - p.ThumbnailUrl = nil - } - - return p.ThumbnailUrl == nil || (len(*p.ThumbnailUrl) <= 255 && urlRegex.MatchString(*p.ThumbnailUrl)) -} - -func (p *panelBody) verifyButtonStyle() bool { - return p.ButtonStyle >= component.ButtonStylePrimary && p.ButtonStyle <= component.ButtonStyleDanger -} - -func (p *panelBody) verifyButtonLabel() bool { - if len(p.ButtonLabel) == 0 { - p.ButtonLabel = p.Title // Title already checked for 80 char max - } - - return len(p.ButtonLabel) > 0 && len(p.ButtonLabel) <= 80 -} - -func (p *panelBody) verifyFormId(guildId uint64) (bool, error) { - if p.FormId == nil { - return true, nil - } else { - form, ok, err := dbclient.Client.Forms.Get(*p.FormId) - if err != nil { - return false, err - } - - if !ok { - return false, nil - } - - if form.GuildId != guildId { - return false, nil - } - - return true, nil - } -} - -func (p *panelBody) verifyTeams(guildId uint64) (bool, error) { - // Query does not work nicely if there are no teams created in the guild, but if the user submits no teams, - // then the input is guaranteed to be valid. - if len(p.Teams) == 0 { - return true, nil - } - - return dbclient.Client.SupportTeam.AllTeamsExistForGuild(guildId, p.Teams) -} - -func (p *panelBody) verifyNamingScheme() bool { - if p.NamingScheme == nil { - return true - } - - if len(*p.NamingScheme) == 0 { - p.NamingScheme = nil - return true - } - - // Substitute out {} users may use by mistake, spaces for dashes and convert to lowercase - p.NamingScheme = utils.Ptr(strings.ReplaceAll(*p.NamingScheme, "{", "%")) - p.NamingScheme = utils.Ptr(strings.ReplaceAll(*p.NamingScheme, "}", "%")) - p.NamingScheme = utils.Ptr(strings.ReplaceAll(*p.NamingScheme, " ", "-")) - p.NamingScheme = utils.Ptr(strings.ToLower(*p.NamingScheme)) - - if len(*p.NamingScheme) > 100 { - return false - } - - // Validate placeholders used - validPlaceholders := []string{"id", "username", "nickname", "id_padded"} - for _, match := range placeholderPattern.FindAllStringSubmatch(*p.NamingScheme, -1) { - if len(match) < 2 { // Infallible - return false - } - - placeholder := match[1] - if !utils.Contains(validPlaceholders, placeholder) { - return false - } - } - - // Discord filters out illegal characters (such as +, $, ") when creating the channel for us - return true -} - -func (p *panelBody) validateWelcomeMessage() bool { - return p.WelcomeMessage == nil || !(p.WelcomeMessage.Title == nil && - p.WelcomeMessage.Description == nil && - len(p.WelcomeMessage.Fields) == 0 && - p.WelcomeMessage.ImageUrl == nil && - p.WelcomeMessage.ThumbnailUrl == nil) -} - func getRoleHashSet(guildId uint64) (*collections.Set[uint64], error) { ctx, err := botcontext.ContextForGuild(guildId) if err != nil { diff --git a/app/http/endpoints/api/panel/panelupdate.go b/app/http/endpoints/api/panel/panelupdate.go index 4dec7a9..58c98bb 100644 --- a/app/http/endpoints/api/panel/panelupdate.go +++ b/app/http/endpoints/api/panel/panelupdate.go @@ -2,6 +2,7 @@ package api import ( "errors" + "github.com/TicketsBot/GoPanel/app/http/validation" "github.com/TicketsBot/GoPanel/botcontext" dbclient "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/rpc" @@ -9,6 +10,7 @@ import ( "github.com/TicketsBot/common/premium" "github.com/TicketsBot/database" "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" "github.com/rxdn/gdl/objects/interaction/component" "github.com/rxdn/gdl/rest" "github.com/rxdn/gdl/rest/request" @@ -54,9 +56,8 @@ func UpdatePanel(ctx *gin.Context) { return } - if !data.doValidations(ctx, guildId) { - return - } + // Apply defaults + ApplyPanelDefaults(&data) premiumTier, err := rpc.PremiumClient.GetTierByGuildId(guildId, true, botContext.Token, botContext.RateLimiter) if err != nil { @@ -64,6 +65,45 @@ func UpdatePanel(ctx *gin.Context) { return } + channels, err := botContext.GetGuildChannels(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Do custom validation + validationContext := PanelValidationContext{ + Data: data, + GuildId: guildId, + IsPremium: premiumTier > premium.None, + BotContext: botContext, + Channels: channels, + } + + if err := ValidatePanelBody(validationContext); err != nil { + var validationError *validation.InvalidInputError + if errors.As(err, &validationError) { + ctx.JSON(400, utils.ErrorStr(validationError.Error())) + } else { + ctx.JSON(500, utils.ErrorJson(err)) + } + + return + } + + // Do tag validation + if err := validate.Struct(data); err != nil { + validationErrors, ok := err.(validator.ValidationErrors) + if !ok { + ctx.JSON(500, utils.ErrorStr("An error occurred while validating the panel")) + return + } + + formatted := "Your input contained the following errors:\n" + utils.FormatValidationErrors(validationErrors) + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + var emojiId *uint64 var emojiName *string { @@ -170,6 +210,7 @@ func UpdatePanel(ctx *gin.Context) { NamingScheme: data.NamingScheme, ForceDisabled: existing.ForceDisabled, Disabled: data.Disabled, + ExitSurveyFormId: data.ExitSurveyFormId, } if err = dbclient.Client.Panel.Update(panel); err != nil { diff --git a/app/http/endpoints/api/panel/validation.go b/app/http/endpoints/api/panel/validation.go new file mode 100644 index 0000000..2a1bc4b --- /dev/null +++ b/app/http/endpoints/api/panel/validation.go @@ -0,0 +1,284 @@ +package api + +import ( + "context" + "errors" + "fmt" + "github.com/TicketsBot/GoPanel/app/http/validation" + "github.com/TicketsBot/GoPanel/app/http/validation/defaults" + "github.com/TicketsBot/GoPanel/botcontext" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/rxdn/gdl/objects/channel" + "github.com/rxdn/gdl/objects/interaction/component" + "regexp" + "strings" + "time" +) + +func ApplyPanelDefaults(data *panelBody) []defaults.DefaultApplicator { + return []defaults.DefaultApplicator{ + defaults.NewDefaultApplicator(defaults.EmptyStringCheck, &data.Title, "Open a ticket!"), + defaults.NewDefaultApplicator(defaults.EmptyStringCheck, &data.Content, "By clicking the button, a ticket will be opened for you."), + defaults.NewDefaultApplicator[*string](defaults.NilOrEmptyStringCheck, &data.ImageUrl, nil), + defaults.NewDefaultApplicator[*string](defaults.NilOrEmptyStringCheck, &data.ThumbnailUrl, nil), + defaults.NewDefaultApplicator(defaults.EmptyStringCheck, &data.ButtonLabel, data.Title), + defaults.NewDefaultApplicator(defaults.EmptyStringCheck, &data.ButtonLabel, "Open a ticket!"), // Title could have been blank + defaults.NewDefaultApplicator[*string](defaults.NilOrEmptyStringCheck, &data.NamingScheme, nil), + } +} + +type PanelValidationContext struct { + Data panelBody + GuildId uint64 + IsPremium bool + BotContext botcontext.BotContext + Channels []channel.Channel +} + +func ValidatePanelBody(validationContext PanelValidationContext) error { + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFunc() + + return validation.Validate(ctx, validationContext, panelValidators()...) +} + +func panelValidators() []validation.Validator[PanelValidationContext] { + return []validation.Validator[PanelValidationContext]{ + validateTitle, + validateContent, + validateChannelId, + validateCategory, + validateEmoji, + validateImageUrl, + validateThumbnailUrl, + validateButtonStyle, + validateButtonLabel, + validateFormId, + validateExitSurveyFormId, + validateTeams, + validateNamingScheme, + validateWelcomeMessage, + } +} + +func validateTitle(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if len(ctx.Data.Title) > 80 { + return validation.NewInvalidInputError("Panel title must be less than 80 characters") + } + + return nil + } +} + +func validateContent(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if len(ctx.Data.Content) > 4096 { + return validation.NewInvalidInputError("Panel content must be less than 4096 characters") + } + + return nil + } +} + +func validateChannelId(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + for _, ch := range ctx.Channels { + if ch.Id == ctx.Data.ChannelId && (ch.Type == channel.ChannelTypeGuildText || ch.Type == channel.ChannelTypeGuildNews) { + return nil + } + } + + return validation.NewInvalidInputError("Panel channel not found") + } +} + +func validateCategory(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + for _, ch := range ctx.Channels { + if ch.Id == ctx.Data.CategoryId && ch.Type == channel.ChannelTypeGuildCategory { + return nil + } + } + + return validation.NewInvalidInputError("Invalid ticket category") + } +} + +func validateEmoji(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + emoji := ctx.Data.Emoji + + if emoji.IsCustomEmoji { + if emoji.Id == nil { + return validation.NewInvalidInputError("Custom emoji was missing ID") + } + + resolvedEmoji, err := ctx.BotContext.GetGuildEmoji(ctx.GuildId, *emoji.Id) + if err != nil { + return err + } + + if resolvedEmoji.Id.Value == 0 { + return validation.NewInvalidInputError("Emoji not found") + } + + if resolvedEmoji.Name != emoji.Name { + return validation.NewInvalidInputError("Emoji name mismatch") + } + } else { + if len(emoji.Name) == 0 { + return validation.NewInvalidInputError("Emoji name was empty") + } + + // Convert from :emoji: to unicode if we need to + name := strings.TrimSpace(emoji.Name) + name = strings.Replace(name, ":", "", -1) + + unicode, ok := utils.GetEmoji(name) + if !ok { + return validation.NewInvalidInputError("Invalid emoji") + } + + emoji.Name = unicode + } + + return nil + } +} + +var urlRegex = regexp.MustCompile(`^https?://([-a-zA-Z0-9@:%._+~#=]{1,256})\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$`) + +func validateNullableUrl(url *string) validation.ValidationFunc { + return func() error { + if url != nil && len(*url) <= 255 && urlRegex.MatchString(*url) { + return validation.NewInvalidInputError("Invalid URL") + } + + return nil + } +} + +func validateImageUrl(ctx PanelValidationContext) validation.ValidationFunc { + return validateNullableUrl(ctx.Data.ImageUrl) +} + +func validateThumbnailUrl(ctx PanelValidationContext) validation.ValidationFunc { + return validateNullableUrl(ctx.Data.ThumbnailUrl) +} + +func validateButtonStyle(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if ctx.Data.ButtonStyle < component.ButtonStylePrimary && ctx.Data.ButtonStyle > component.ButtonStyleDanger { + return validation.NewInvalidInputError("Invalid button style") + } + + return nil + } +} + +func validateButtonLabel(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if len(ctx.Data.ButtonLabel) > 80 { + return validation.NewInvalidInputError("Button label must be less than 80 characters") + } + + return nil + } +} + +func validatedNullableFormId(guildId uint64, formId *int) validation.ValidationFunc { + return func() error { + if formId == nil { + return nil + } + + form, ok, err := dbclient.Client.Forms.Get(*formId) + if err != nil { + return err + } + + if !ok { + return validation.NewInvalidInputError("Form not found") + } + + if form.GuildId != guildId { + return validation.NewInvalidInputError("Guild ID mismatch when validating form") + } + + return nil + } +} + +func validateFormId(ctx PanelValidationContext) validation.ValidationFunc { + return validatedNullableFormId(ctx.GuildId, ctx.Data.FormId) +} + +// Check premium on the worker side to maintain settings if user unsubscribes and later resubscribes +func validateExitSurveyFormId(ctx PanelValidationContext) validation.ValidationFunc { + return validatedNullableFormId(ctx.GuildId, ctx.Data.ExitSurveyFormId) +} + +func validateTeams(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + // Query does not work nicely if there are no teams created in the guild, but if the user submits no teams, + // then the input is guaranteed to be valid. Teams array excludes default team. + if len(ctx.Data.Teams) == 0 { + return nil + } + + ok, err := dbclient.Client.SupportTeam.AllTeamsExistForGuild(ctx.GuildId, ctx.Data.Teams) + if err != nil { + return err + } + + if !ok { + return validation.NewInvalidInputError("Invalid support team") + } + + return nil + } +} + +var placeholderPattern = regexp.MustCompile(`%(\w+)%`) + +// Discord filters out illegal characters (such as +, $, ") when creating the channel for us +func validateNamingScheme(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if ctx.Data.NamingScheme == nil { + return nil + } + + if len(*ctx.Data.NamingScheme) > 100 { + return validation.NewInvalidInputError("Naming scheme must be less than 100 characters") + } + + // Validate placeholders used + validPlaceholders := []string{"id", "username", "nickname", "id_padded"} + for _, match := range placeholderPattern.FindAllStringSubmatch(*ctx.Data.NamingScheme, -1) { + if len(match) < 2 { // Infallible + return errors.New("Infallible: Regex match length was < 2") + } + + placeholder := match[1] + if !utils.Contains(validPlaceholders, placeholder) { + return validation.NewInvalidInputError(fmt.Sprintf("Invalid naming scheme placeholder: %s", placeholder)) + } + } + + return nil + } +} + +func validateWelcomeMessage(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + wm := ctx.Data.WelcomeMessage + + if wm == nil || wm.Title != nil || wm.Description != nil || len(wm.Fields) > 0 || wm.ImageUrl != nil || wm.ThumbnailUrl != nil { + return nil + } + + return validation.NewInvalidInputError("Welcome message has no content") + } +} diff --git a/app/http/endpoints/api/transcripts/get.go b/app/http/endpoints/api/transcripts/get.go index 792341a..2eb1f97 100644 --- a/app/http/endpoints/api/transcripts/get.go +++ b/app/http/endpoints/api/transcripts/get.go @@ -46,7 +46,7 @@ func GetTranscriptHandler(ctx *gin.Context) { } if !hasPermission { - ctx.JSON(403, utils.ErrorStr("You do not have permission to view this transcript")) + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this transcriptMetadata")) return } } diff --git a/app/http/endpoints/api/transcripts/list.go b/app/http/endpoints/api/transcripts/list.go index 258344d..1abdfed 100644 --- a/app/http/endpoints/api/transcripts/list.go +++ b/app/http/endpoints/api/transcripts/list.go @@ -10,10 +10,11 @@ import ( const pageLimit = 15 -type transcript struct { +type transcriptMetadata struct { TicketId int `json:"ticket_id"` Username string `json:"username"` CloseReason *string `json:"close_reason"` + ClosedBy *uint64 `json:"closed_by"` Rating *uint8 `json:"rating"` HasTranscript bool `json:"has_transcript"` } @@ -88,9 +89,9 @@ func ListTranscripts(ctx *gin.Context) { return } - transcripts := make([]transcript, len(tickets)) + transcripts := make([]transcriptMetadata, len(tickets)) for i, ticket := range tickets { - transcript := transcript{ + transcript := transcriptMetadata{ TicketId: ticket.Id, Username: usernames[ticket.UserId], HasTranscript: ticket.HasTranscript, @@ -101,7 +102,8 @@ func ListTranscripts(ctx *gin.Context) { } if v, ok := closeReasons[ticket.Id]; ok { - transcript.CloseReason = &v + transcript.CloseReason = v.Reason + transcript.ClosedBy = v.ClosedBy } transcripts[i] = transcript diff --git a/app/http/endpoints/api/transcripts/render.go b/app/http/endpoints/api/transcripts/render.go index 4e5661f..96ada2e 100644 --- a/app/http/endpoints/api/transcripts/render.go +++ b/app/http/endpoints/api/transcripts/render.go @@ -47,7 +47,7 @@ func GetTranscriptRenderHandler(ctx *gin.Context) { } if !hasPermission { - ctx.JSON(403, utils.ErrorStr("You do not have permission to view this transcript")) + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this transcriptMetadata")) return } } diff --git a/app/http/validation/defaults/applicator.go b/app/http/validation/defaults/applicator.go new file mode 100644 index 0000000..5815571 --- /dev/null +++ b/app/http/validation/defaults/applicator.go @@ -0,0 +1,26 @@ +package defaults + +type ShouldApplyCheck func() bool +type ApplicatorFunc func() + +type DefaultApplicator struct { + ShouldApply ShouldApplyCheck + Apply ApplicatorFunc +} + +func NewDefaultApplicator[T any](shouldApplyGenerator func(T) ShouldApplyCheck, ptr *T, defaultValue T) DefaultApplicator { + return DefaultApplicator{ + ShouldApply: shouldApplyGenerator(*ptr), + Apply: func() { + *ptr = defaultValue + }, + } +} + +func ApplyDefaults(applicators ...DefaultApplicator) { + for _, applicator := range applicators { + if applicator.ShouldApply() { + applicator.Apply() + } + } +} diff --git a/app/http/validation/defaults/checks.go b/app/http/validation/defaults/checks.go new file mode 100644 index 0000000..eb85a6d --- /dev/null +++ b/app/http/validation/defaults/checks.go @@ -0,0 +1,19 @@ +package defaults + +func EmptyStringCheck(s string) ShouldApplyCheck { + return func() bool { + return len(s) == 0 + } +} + +func NilCheck[T any](v *T) ShouldApplyCheck { + return func() bool { + return v == nil + } +} + +func NilOrEmptyStringCheck(s *string) ShouldApplyCheck { + return func() bool { + return s == nil || len(*s) == 0 + } +} diff --git a/app/http/validation/defaults/defaults_test.go b/app/http/validation/defaults/defaults_test.go new file mode 100644 index 0000000..7d567ed --- /dev/null +++ b/app/http/validation/defaults/defaults_test.go @@ -0,0 +1,13 @@ +package defaults + +import ( + "github.com/TicketsBot/GoPanel/utils" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNil(t *testing.T) { + var myString *string + ApplyDefaults(NewDefaultApplicator[*string](NilCheck[string], &myString, utils.Ptr("hello"))) + assert.Equal(t, "hello", *myString) +} diff --git a/app/http/validation/error.go b/app/http/validation/error.go new file mode 100644 index 0000000..a644499 --- /dev/null +++ b/app/http/validation/error.go @@ -0,0 +1,13 @@ +package validation + +type InvalidInputError struct { + Message string +} + +func (e *InvalidInputError) Error() string { + return e.Message +} + +func NewInvalidInputError(message string) *InvalidInputError { + return &InvalidInputError{Message: message} +} diff --git a/app/http/validation/validation_test.go b/app/http/validation/validation_test.go new file mode 100644 index 0000000..8a52858 --- /dev/null +++ b/app/http/validation/validation_test.go @@ -0,0 +1,81 @@ +package validation + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "testing" +) + +type testCtx struct { + a int + b string +} + +func validateGreaterThanZero(ctx testCtx) ValidationFunc { + return func() error { + if ctx.a <= 0 { + return NewInvalidInputError("a must be greater than 0") + } + + return nil + } +} + +func validateStringNotEmpty(ctx testCtx) ValidationFunc { + return func() error { + if len(ctx.b) == 0 { + return NewInvalidInputError("b must be greater than 0") + } + + return nil + } +} + +func TestSuccessful(t *testing.T) { + ctx := testCtx{a: 1, b: "test"} + + if err := Validate(context.Background(), ctx, validateGreaterThanZero, validateStringNotEmpty); err != nil { + t.Error(err) + } +} + +func TestNoValidators(t *testing.T) { + ctx := testCtx{a: 1, b: "test"} + + if err := Validate(context.Background(), ctx); err != nil { + t.Error(err) + } +} + +func TestSingleFail(t *testing.T) { + ctx := testCtx{a: 1, b: ""} + err := Validate(context.Background(), ctx, validateGreaterThanZero, validateStringNotEmpty) + if err == nil { + t.Fatal("expected error") + } + + var validationError *InvalidInputError + if !errors.As(err, &validationError) { + t.Fatal("expected InvalidInputError error") + } + + assert.Equal(t, "b must be greater than 0", validationError.Message) +} + +func TestDualFail(t *testing.T) { + ctx := testCtx{a: 0, b: ""} + err := Validate(context.Background(), ctx, validateGreaterThanZero, validateStringNotEmpty) + if err == nil { + t.Error("expected error") + } + + var validationError *InvalidInputError + if !errors.As(err, &validationError) { + t.Error("expected InvalidInputError error") + } + + if validationError.Message != "a must be greater than 0" && validationError.Message != "b must be greater than 0" { + t.Errorf("got wrong error message: %s", validationError.Message) + } +} diff --git a/app/http/validation/validator.go b/app/http/validation/validator.go new file mode 100644 index 0000000..fe18553 --- /dev/null +++ b/app/http/validation/validator.go @@ -0,0 +1,20 @@ +package validation + +import ( + "context" + "golang.org/x/sync/errgroup" +) + +type Validator[T any] func(validationContext T) ValidationFunc +type ValidationFunc func() error + +func Validate[T any](ctx context.Context, validationContext T, validators ...Validator[T]) error { + group, _ := errgroup.WithContext(ctx) + + for _, validator := range validators { + validator := validator + group.Go(validator(validationContext)) + } + + return group.Wait() +} diff --git a/botcontext/botcontext.go b/botcontext/botcontext.go index f538094..27430c0 100644 --- a/botcontext/botcontext.go +++ b/botcontext/botcontext.go @@ -8,6 +8,7 @@ import ( "github.com/TicketsBot/common/permission" "github.com/TicketsBot/common/restcache" "github.com/TicketsBot/database" + "github.com/rxdn/gdl/objects/channel" "github.com/rxdn/gdl/objects/guild" "github.com/rxdn/gdl/objects/guild/emoji" "github.com/rxdn/gdl/objects/interaction" @@ -111,6 +112,24 @@ func (ctx BotContext) GetGuildRoles(guildId uint64) (roles []guild.Role, err err return ctx.RestCache.GetGuildRoles(guildId) } +func (ctx BotContext) GetGuildChannels(guildId uint64) ([]channel.Channel, error) { + cachedChannels := cache.Instance.GetGuildChannels(guildId) + if len(cachedChannels) == 0 { + // If guild is cached but not any channels, likely that it does truly have 0 channels, so don't fetch from REST + _, ok := cache.Instance.GetGuild(guildId) + if ok { + return []channel.Channel{}, nil + } + } + + channels, err := rest.GetGuildChannels(ctx.Token, ctx.RateLimiter, guildId) + if err == nil { + go cache.Instance.StoreChannels(channels) + } + + return channels, err +} + func (ctx BotContext) GetGuildEmoji(guildId, emojiId uint64) (emoji.Emoji, error) { if emoji, ok := cache.Instance.GetEmoji(guildId); ok { return emoji, nil diff --git a/frontend/src/components/form/Dropdown.svelte b/frontend/src/components/form/Dropdown.svelte index 7bd72f6..a99574a 100644 --- a/frontend/src/components/form/Dropdown.svelte +++ b/frontend/src/components/form/Dropdown.svelte @@ -1,6 +1,13 @@
{#if label !== undefined} - +
+ + {#if premiumBadge} +
+ +
+ {/if} +
{/if}