diff --git a/app/http/endpoints/api/panel/panelcreate.go b/app/http/endpoints/api/panel/panelcreate.go index 3607f43..aee45c1 100644 --- a/app/http/endpoints/api/panel/panelcreate.go +++ b/app/http/endpoints/api/panel/panelcreate.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "github.com/TicketsBot/GoPanel/app/http/validation" "github.com/TicketsBot/GoPanel/botcontext" @@ -8,11 +9,11 @@ import ( "github.com/TicketsBot/GoPanel/rpc" "github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils/types" - "github.com/TicketsBot/common/collections" "github.com/TicketsBot/common/premium" "github.com/TicketsBot/database" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/jackc/pgx/v4" "github.com/rxdn/gdl/objects/guild/emoji" "github.com/rxdn/gdl/objects/interaction/component" "github.com/rxdn/gdl/rest/request" @@ -22,25 +23,26 @@ import ( const freePanelLimit = 3 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"` - ExitSurveyFormId *int `json:"exit_survey_form_id"` + 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"` + AccessControlList []database.PanelAccessControlRule `json:"access_control_list"` } func (p *panelBody) IntoPanelMessageData(customId string, isPremium bool) panelMessageData { @@ -109,6 +111,12 @@ func CreatePanel(ctx *gin.Context) { return } + roles, err := botContext.GetGuildRoles(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + // Do custom validation validationContext := PanelValidationContext{ Data: data, @@ -116,6 +124,7 @@ func CreatePanel(ctx *gin.Context) { IsPremium: premiumTier > premium.None, BotContext: botContext, Channels: channels, + Roles: roles, } if err := ValidatePanelBody(validationContext); err != nil { @@ -215,30 +224,19 @@ func CreatePanel(ctx *gin.Context) { ExitSurveyFormId: data.ExitSurveyFormId, } - panelId, err := dbclient.Client.Panel.Create(panel) - if err != nil { - ctx.AbortWithStatusJSON(500, gin.H{ - "success": false, - "error": err.Error(), - }) - return + createOptions := panelCreateOptions{ + TeamIds: data.Teams, // Already validated + AccessControlRules: data.AccessControlList, // Already validated } // insert role mention data // string is role ID or "user" to mention the ticket opener - validRoles, err := getRoleHashSet(guildId) - if err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } + validRoles := utils.ToSet(utils.Map(roles, utils.RoleToId)) var roleMentions []uint64 for _, mention := range data.Mentions { if mention == "user" { - if err = dbclient.Client.PanelUserMention.Set(panelId, true); err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } + createOptions.ShouldMentionUser = true } else { roleId, err := strconv.ParseUint(mention, 10, 64) if err != nil { @@ -247,18 +245,13 @@ func CreatePanel(ctx *gin.Context) { } if validRoles.Contains(roleId) { - roleMentions = append(roleMentions, roleId) + createOptions.RoleMentions = append(roleMentions, roleId) } } } - if err := dbclient.Client.PanelRoleMentions.Replace(panelId, roleMentions); err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } - - // Already validated, we are safe to insert - if err := dbclient.Client.PanelTeams.Replace(panelId, data.Teams); err != nil { + panelId, err := storePanel(ctx, panel, createOptions) + if err != nil { ctx.JSON(500, utils.ErrorJson(err)) return } @@ -269,27 +262,52 @@ func CreatePanel(ctx *gin.Context) { }) } +// DB functions + +type panelCreateOptions struct { + ShouldMentionUser bool + RoleMentions []uint64 + TeamIds []int + AccessControlRules []database.PanelAccessControlRule +} + +func storePanel(ctx context.Context, panel database.Panel, options panelCreateOptions) (int, error) { + var panelId int + err := dbclient.Client.Panel.BeginFunc(ctx, func(tx pgx.Tx) error { + var err error + panelId, err = dbclient.Client.Panel.CreateWithTx(tx, panel) + if err != nil { + return err + } + + if err := dbclient.Client.PanelUserMention.SetWithTx(tx, panelId, options.ShouldMentionUser); err != nil { + return err + } + + if err := dbclient.Client.PanelRoleMentions.ReplaceWithTx(tx, panelId, options.RoleMentions); err != nil { + return err + } + + // Already validated, we are safe to insert + if err := dbclient.Client.PanelTeams.ReplaceWithTx(tx, panelId, options.TeamIds); err != nil { + return err + } + + if err := dbclient.Client.PanelAccessControlRules.ReplaceWithTx(ctx, tx, panelId, options.AccessControlRules); err != nil { + return err + } + + return nil + }) + + if err != nil { + return 0, err + } + + return panelId, nil +} + // Data must be validated before calling this function func (p *panelBody) getEmoji() *emoji.Emoji { return p.Emoji.IntoGdl() } - -func getRoleHashSet(guildId uint64) (*collections.Set[uint64], error) { - ctx, err := botcontext.ContextForGuild(guildId) - if err != nil { - return nil, err - } - - roles, err := ctx.GetGuildRoles(guildId) - if err != nil { - return nil, err - } - - set := collections.NewSet[uint64]() - - for _, role := range roles { - set.Add(role.Id) - } - - return set, nil -} diff --git a/app/http/endpoints/api/panel/panellist.go b/app/http/endpoints/api/panel/panellist.go index ec9c7e3..2afc62e 100644 --- a/app/http/endpoints/api/panel/panellist.go +++ b/app/http/endpoints/api/panel/panellist.go @@ -14,12 +14,13 @@ import ( func ListPanels(ctx *gin.Context) { type panelResponse struct { database.Panel - WelcomeMessage *types.CustomEmbed `json:"welcome_message"` - UseCustomEmoji bool `json:"use_custom_emoji"` - Emoji types.Emoji `json:"emote"` - Mentions []string `json:"mentions"` - Teams []int `json:"teams"` - UseServerDefaultNamingScheme bool `json:"use_server_default_naming_scheme"` + WelcomeMessage *types.CustomEmbed `json:"welcome_message"` + UseCustomEmoji bool `json:"use_custom_emoji"` + Emoji types.Emoji `json:"emote"` + Mentions []string `json:"mentions"` + Teams []int `json:"teams"` + UseServerDefaultNamingScheme bool `json:"use_server_default_naming_scheme"` + AccessControlList []database.PanelAccessControlRule `json:"access_control_list"` } guildId := ctx.Keys["guildid"].(uint64) @@ -30,6 +31,12 @@ func ListPanels(ctx *gin.Context) { return } + accessControlLists, err := dbclient.Client.PanelAccessControlRules.GetAllForGuild(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + allFields, err := dbclient.Client.EmbedFields.GetAllFieldsForPanels(guildId) if err != nil { ctx.JSON(500, utils.ErrorJson(err)) @@ -85,6 +92,11 @@ func ListPanels(ctx *gin.Context) { welcomeMessage = types.NewCustomEmbed(p.WelcomeMessage, fields) } + accessControlList := accessControlLists[p.PanelId] + if accessControlList == nil { + accessControlList = make([]database.PanelAccessControlRule, 0) + } + wrapped[i] = panelResponse{ Panel: p.Panel, WelcomeMessage: welcomeMessage, @@ -93,6 +105,7 @@ func ListPanels(ctx *gin.Context) { Mentions: mentions, Teams: teamIds, UseServerDefaultNamingScheme: p.NamingScheme == nil, + AccessControlList: accessControlList, } return nil diff --git a/app/http/endpoints/api/panel/panelupdate.go b/app/http/endpoints/api/panel/panelupdate.go index 58c98bb..fc9aa27 100644 --- a/app/http/endpoints/api/panel/panelupdate.go +++ b/app/http/endpoints/api/panel/panelupdate.go @@ -11,6 +11,7 @@ import ( "github.com/TicketsBot/database" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/jackc/pgx/v4" "github.com/rxdn/gdl/objects/interaction/component" "github.com/rxdn/gdl/rest" "github.com/rxdn/gdl/rest/request" @@ -71,6 +72,12 @@ func UpdatePanel(ctx *gin.Context) { return } + roles, err := botContext.GetGuildRoles(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + // Do custom validation validationContext := PanelValidationContext{ Data: data, @@ -78,6 +85,7 @@ func UpdatePanel(ctx *gin.Context) { IsPremium: premiumTier > premium.None, BotContext: botContext, Channels: channels, + Roles: roles, } if err := ValidatePanelBody(validationContext); err != nil { @@ -213,17 +221,8 @@ func UpdatePanel(ctx *gin.Context) { ExitSurveyFormId: data.ExitSurveyFormId, } - if err = dbclient.Client.Panel.Update(panel); err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } - // insert mention data - validRoles, err := getRoleHashSet(guildId) - if err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } + validRoles := utils.ToSet(utils.Map(roles, utils.RoleToId)) // string is role ID or "user" to mention the ticket opener var shouldMentionUser bool @@ -244,22 +243,37 @@ func UpdatePanel(ctx *gin.Context) { } } - if err := dbclient.Client.PanelUserMention.Set(panel.PanelId, shouldMentionUser); err != nil { - ctx.AbortWithStatusJSON(500, utils.ErrorJson(err)) - return - } + err = dbclient.Client.Panel.BeginFunc(ctx, func(tx pgx.Tx) error { + if err := dbclient.Client.Panel.UpdateWithTx(tx, panel); err != nil { + return err + } - if err := dbclient.Client.PanelRoleMentions.Replace(panel.PanelId, roleMentions); err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } - - // We are safe to insert, team IDs already validated - if err := dbclient.Client.PanelTeams.Replace(panel.PanelId, data.Teams); err != nil { + if err := dbclient.Client.PanelUserMention.SetWithTx(tx, panel.PanelId, shouldMentionUser); err != nil { + return err + } + + if err := dbclient.Client.PanelRoleMentions.ReplaceWithTx(tx, panel.PanelId, roleMentions); err != nil { + return err + } + + // We are safe to insert, team IDs already validated + if err := dbclient.Client.PanelTeams.ReplaceWithTx(tx, panel.PanelId, data.Teams); err != nil { + return err + } + + if err := dbclient.Client.PanelAccessControlRules.ReplaceWithTx(ctx, tx, panel.PanelId, data.AccessControlList); err != nil { + return err + } + + return nil + }) + + if err != nil { ctx.JSON(500, utils.ErrorJson(err)) return } + // This doesn't need to be done in a transaction // Update multi panels // check if this will break a multi-panel; diff --git a/app/http/endpoints/api/panel/validation.go b/app/http/endpoints/api/panel/validation.go index 8680a6f..05db7cc 100644 --- a/app/http/endpoints/api/panel/validation.go +++ b/app/http/endpoints/api/panel/validation.go @@ -9,14 +9,24 @@ import ( "github.com/TicketsBot/GoPanel/botcontext" dbclient "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" "github.com/rxdn/gdl/objects/channel" + "github.com/rxdn/gdl/objects/guild" "github.com/rxdn/gdl/objects/interaction/component" "regexp" "strings" "time" ) -func ApplyPanelDefaults(data *panelBody) []defaults.DefaultApplicator { +func ApplyPanelDefaults(data *panelBody) { + for _, applicator := range DefaultApplicators(data) { + if applicator.ShouldApply() { + applicator.Apply() + } + } +} + +func DefaultApplicators(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."), @@ -34,6 +44,7 @@ type PanelValidationContext struct { IsPremium bool BotContext botcontext.BotContext Channels []channel.Channel + Roles []guild.Role } func ValidatePanelBody(validationContext PanelValidationContext) error { @@ -59,6 +70,7 @@ func panelValidators() []validation.Validator[PanelValidationContext] { validateTeams, validateNamingScheme, validateWelcomeMessage, + validateAccessControlList, } } @@ -282,3 +294,44 @@ func validateWelcomeMessage(ctx PanelValidationContext) validation.ValidationFun return validation.NewInvalidInputError("Welcome message has no content") } } + +func validateAccessControlList(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + acl := ctx.Data.AccessControlList + + if len(acl) == 0 { + return validation.NewInvalidInputError("Access control list is empty") + } + + if len(acl) > 10 { + return validation.NewInvalidInputError("Access control list cannot have more than 10 roles") + } + + roles := utils.ToSet(utils.Map(ctx.Roles, utils.RoleToId)) + + if roles.Size() != len(ctx.Roles) { + return validation.NewInvalidInputError("Duplicate roles in access control list") + } + + everyoneRoleFound := false + for _, rule := range acl { + if rule.RoleId == ctx.GuildId { + everyoneRoleFound = true + } + + if rule.Action != database.AccessControlActionDeny && rule.Action != database.AccessControlActionAllow { + return validation.NewInvalidInputErrorf("Invalid access control action \"%s\"", rule.Action) + } + + if !roles.Contains(rule.RoleId) { + return validation.NewInvalidInputErrorf("Invalid role %d in access control list not found in the guild", rule.RoleId) + } + } + + if !everyoneRoleFound { + return validation.NewInvalidInputError("Access control list does not contain @everyone rule") + } + + return nil + } +} diff --git a/app/http/validation/error.go b/app/http/validation/error.go index a644499..dec2f75 100644 --- a/app/http/validation/error.go +++ b/app/http/validation/error.go @@ -1,5 +1,7 @@ package validation +import "fmt" + type InvalidInputError struct { Message string } @@ -11,3 +13,7 @@ func (e *InvalidInputError) Error() string { func NewInvalidInputError(message string) *InvalidInputError { return &InvalidInputError{Message: message} } + +func NewInvalidInputErrorf(message string, args ...any) *InvalidInputError { + return &InvalidInputError{Message: fmt.Sprintf(message, args...)} +} diff --git a/frontend/src/components/WrappedSelect.svelte b/frontend/src/components/WrappedSelect.svelte index 33aea77..0a08436 100644 --- a/frontend/src/components/WrappedSelect.svelte +++ b/frontend/src/components/WrappedSelect.svelte @@ -10,6 +10,7 @@ {loadOptions} {placeholder} {placeholderAlwaysShow} + {disabled} multiple={isMulti} --background="#2e3136" --border="#2e3136" @@ -46,6 +47,7 @@ export let placeholderAlwaysShow = false; export let loadOptions; export let loadOptionsInterval; + export let disabled = false; export let optionIdentifier; export let nameMapper = (x) => x; diff --git a/frontend/src/components/form/RoleSelect.svelte b/frontend/src/components/form/RoleSelect.svelte index 9772633..07be3a9 100644 --- a/frontend/src/components/form/RoleSelect.svelte +++ b/frontend/src/components/form/RoleSelect.svelte @@ -2,8 +2,8 @@ {/if} - + + + \ No newline at end of file diff --git a/frontend/src/components/manage/PanelCreationForm.svelte b/frontend/src/components/manage/PanelCreationForm.svelte index 7ba593d..250c42c 100644 --- a/frontend/src/components/manage/PanelCreationForm.svelte +++ b/frontend/src/components/manage/PanelCreationForm.svelte @@ -154,28 +154,38 @@ + + + Access Control +
+
+

Control who can open tickets with from this panel. Rules are evaluated from top to bottom, + stopping after the first match.

+
+
+ +
+
+