diff --git a/app/http/endpoints/api/forms/createform.go b/app/http/endpoints/api/forms/createform.go new file mode 100644 index 0000000..20fd271 --- /dev/null +++ b/app/http/endpoints/api/forms/createform.go @@ -0,0 +1,45 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" +) + +type createFormBody struct { + Title string `json:"title"` +} + +func CreateForm(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + var data createFormBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if len(data.Title) > 255 { + ctx.JSON(400, utils.ErrorStr("Title is too long")) + return + } + + // 26^50 chance of collision + customId := utils.RandString(50) + + id, err := dbclient.Client.Forms.Create(guildId, data.Title, customId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + form := database.Form{ + Id: id, + GuildId: guildId, + Title: data.Title, + CustomId: customId, + } + + ctx.JSON(200, form) +} diff --git a/app/http/endpoints/api/forms/createinput.go b/app/http/endpoints/api/forms/createinput.go new file mode 100644 index 0000000..1a65ce0 --- /dev/null +++ b/app/http/endpoints/api/forms/createinput.go @@ -0,0 +1,119 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/rxdn/gdl/objects/interaction/component" + "strconv" +) + +type inputCreateBody struct { + Style component.TextStyleTypes `json:"style"` + Label string `json:"label"` + Placeholder *string `json:"placeholder"` +} + +func CreateInput(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + var data inputCreateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + // Validate body + if !data.Validate(ctx) { + return + } + + // Parse form ID from URL + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + // Get form and validate it belongs to the guild + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + // Check there are not more than 25 inputs already + // TODO: This is vulnerable to a race condition + inputCount, err := getFormInputCount(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if inputCount >= 5 { + ctx.JSON(400, utils.ErrorStr("A form cannot have more than 5 inputs")) + return + } + + // 2^30 chance of collision + customId := utils.RandString(30) + + formInputId, err := dbclient.Client.FormInput.Create(formId, customId, uint8(data.Style), data.Label, data.Placeholder) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, database.FormInput{ + Id: formInputId, + FormId: formId, + CustomId: customId, + Style: uint8(data.Style), + Label: data.Label, + Placeholder: data.Placeholder, + }) +} + +func (b *inputCreateBody) Validate(ctx *gin.Context) bool { + if b.Style != component.TextStyleShort && b.Style != component.TextStyleParagraph { + ctx.JSON(400, utils.ErrorStr("Invalid style")) + return false + } + + if len(b.Label) == 0 || len(b.Label) > 255 { + ctx.JSON(400, utils.ErrorStr("The input label must be between 1 and 255 characters")) + return false + } + + if b.Placeholder != nil && len(*b.Placeholder) == 0 { + b.Placeholder = nil + } + + if b.Placeholder != nil && len(*b.Placeholder) > 100 { + ctx.JSON(400, utils.ErrorStr("The placeholder cannot be more than 100 characters")) + return false + } + + return true +} + +// TODO: Use select count() +func getFormInputCount(formId int) (int, error) { + inputs, err := dbclient.Client.FormInput.GetInputs(formId) + if err != nil { + return 0, err + } + + return len(inputs), nil +} diff --git a/app/http/endpoints/api/forms/deleteform.go b/app/http/endpoints/api/forms/deleteform.go new file mode 100644 index 0000000..30fd2a0 --- /dev/null +++ b/app/http/endpoints/api/forms/deleteform.go @@ -0,0 +1,41 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func DeleteForm(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + if err := dbclient.Client.Forms.Delete(formId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, utils.SuccessResponse) +} diff --git a/app/http/endpoints/api/forms/deleteinput.go b/app/http/endpoints/api/forms/deleteinput.go new file mode 100644 index 0000000..c11109d --- /dev/null +++ b/app/http/endpoints/api/forms/deleteinput.go @@ -0,0 +1,63 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func DeleteInput(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + inputId, err := strconv.Atoi(ctx.Param("input_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + input, ok, err := dbclient.Client.FormInput.Get(inputId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Input not found")) + return + } + + if input.FormId != formId { + ctx.JSON(403, utils.ErrorStr("Input does not belong to this form")) + return + } + + if err := dbclient.Client.FormInput.Delete(input.Id, input.FormId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, utils.SuccessResponse) +} diff --git a/app/http/endpoints/api/forms/getforms.go b/app/http/endpoints/api/forms/getforms.go new file mode 100644 index 0000000..79191f3 --- /dev/null +++ b/app/http/endpoints/api/forms/getforms.go @@ -0,0 +1,28 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" +) + +func GetForms(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + forms, err := dbclient.Client.Forms.GetForms(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + inputs, err := dbclient.Client.FormInput.GetInputsForGuild(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, gin.H{ + "forms": forms, + "inputs": inputs, + }) +} diff --git a/app/http/endpoints/api/forms/updateform.go b/app/http/endpoints/api/forms/updateform.go new file mode 100644 index 0000000..d215063 --- /dev/null +++ b/app/http/endpoints/api/forms/updateform.go @@ -0,0 +1,52 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func UpdateForm(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + var data createFormBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if len(data.Title) > 255 { + ctx.JSON(400, utils.ErrorStr("Title is too long")) + return + } + + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + if err := dbclient.Client.Forms.UpdateTitle(formId, data.Title); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, utils.SuccessResponse) +} diff --git a/app/http/endpoints/api/forms/updateinput.go b/app/http/endpoints/api/forms/updateinput.go new file mode 100644 index 0000000..9a3927a --- /dev/null +++ b/app/http/endpoints/api/forms/updateinput.go @@ -0,0 +1,83 @@ +package forms + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "strconv" +) + +func UpdateInput(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + var data inputCreateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if !data.Validate(ctx) { + return + } + + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + inputId, err := strconv.Atoi(ctx.Param("input_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + input, ok, err := dbclient.Client.FormInput.Get(inputId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Input not found")) + return + } + + if input.FormId != formId { + ctx.JSON(403, utils.ErrorStr("Input does not belong to this form")) + return + } + + newInput := database.FormInput{ + Id: inputId, + FormId: formId, + CustomId: input.CustomId, + Style: uint8(data.Style), + Label: data.Label, + Placeholder: data.Placeholder, + } + + if err := dbclient.Client.FormInput.Update(newInput); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, newInput) +} diff --git a/app/http/endpoints/api/panel/panelcreate.go b/app/http/endpoints/api/panel/panelcreate.go index 8328387..ca0527b 100644 --- a/app/http/endpoints/api/panel/panelcreate.go +++ b/app/http/endpoints/api/panel/panelcreate.go @@ -38,6 +38,7 @@ type panelBody struct { ImageUrl *string `json:"image_url,omitempty"` ThumbnailUrl *string `json:"thumbnail_url,omitempty"` ButtonStyle component.ButtonStyle `json:"button_style,string"` + FormId int `json:"form_id"` } func (p *panelBody) IntoPanelMessageData(customId string, isPremium bool) panelMessageData { @@ -138,6 +139,11 @@ func CreatePanel(ctx *gin.Context) { return } + var formId *int + if data.FormId != 0 { // Already validated + formId = &data.FormId + } + // Store in DB panel := database.Panel{ MessageId: msgId, @@ -154,6 +160,7 @@ func CreatePanel(ctx *gin.Context) { ImageUrl: data.ImageUrl, ThumbnailUrl: data.ThumbnailUrl, ButtonStyle: int(data.ButtonStyle), + FormId: formId, } panelId, err := dbclient.Client.Panel.Create(panel) @@ -305,6 +312,19 @@ func (p *panelBody) doValidations(ctx *gin.Context, guildId uint64) bool { return false } + { + ok, err := p.verifyFormId(guildId) + if err != nil { + ctx.AbortWithStatusJSON(500, utils.ErrorJson(err)) + return false + } + + if !ok { + ctx.AbortWithStatusJSON(400, utils.ErrorStr("Guild ID for form does not match")) + return false + } + } + return true } @@ -383,3 +403,24 @@ func (p *panelBody) verifyThumbnailUrl() bool { func (p *panelBody) verifyButtonStyle() bool { return p.ButtonStyle >= component.ButtonStylePrimary && p.ButtonStyle <= component.ButtonStyleDanger } + +func (p *panelBody) verifyFormId(guildId uint64) (bool, error) { + if p.FormId == 0 { // TODO: Use 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 + } +} diff --git a/app/http/endpoints/api/panel/panelupdate.go b/app/http/endpoints/api/panel/panelupdate.go index 5288dfa..55e1d3c 100644 --- a/app/http/endpoints/api/panel/panelupdate.go +++ b/app/http/endpoints/api/panel/panelupdate.go @@ -84,12 +84,12 @@ func UpdatePanel(ctx *gin.Context) { } messageData := multiPanelMessageData{ - Title: multiPanel.Title, - Content: multiPanel.Content, - Colour: multiPanel.Colour, - ChannelId: multiPanel.ChannelId, + Title: multiPanel.Title, + Content: multiPanel.Content, + Colour: multiPanel.Colour, + ChannelId: multiPanel.ChannelId, SelectMenu: multiPanel.SelectMenu, - IsPremium: premiumTier > premium.None, + IsPremium: premiumTier > premium.None, } messageId, err := messageData.send(&botContext, panels) @@ -142,6 +142,12 @@ func UpdatePanel(ctx *gin.Context) { } } + // Already validated + var formId *int + if data.FormId != 0 { + formId = &data.FormId + } + // Store in DB panel := database.Panel{ PanelId: panelId, @@ -159,6 +165,7 @@ func UpdatePanel(ctx *gin.Context) { ImageUrl: data.ImageUrl, ThumbnailUrl: data.ThumbnailUrl, ButtonStyle: int(data.ButtonStyle), + FormId: formId, } if err = dbclient.Client.Panel.Update(panel); err != nil { diff --git a/app/http/server.go b/app/http/server.go index 150db3d..4ae2e3b 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -5,6 +5,7 @@ import ( api_autoclose "github.com/TicketsBot/GoPanel/app/http/endpoints/api/autoclose" api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist" api_customisation "github.com/TicketsBot/GoPanel/app/http/endpoints/api/customisation" + api_forms "github.com/TicketsBot/GoPanel/app/http/endpoints/api/forms" api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel" api_settings "github.com/TicketsBot/GoPanel/app/http/endpoints/api/settings" api_tags "github.com/TicketsBot/GoPanel/app/http/endpoints/api/tags" @@ -102,6 +103,14 @@ func StartServer() { guildAuthApiAdmin.PATCH("/multipanels/:panelid", api_panels.MultiPanelUpdate) guildAuthApiAdmin.DELETE("/multipanels/:panelid", api_panels.MultiPanelDelete) + guildAuthApiSupport.GET("/forms", api_forms.GetForms) + guildAuthApiAdmin.POST("/forms", rl(middleware.RateLimitTypeGuild, 30, time.Hour), api_forms.CreateForm) + guildAuthApiAdmin.PATCH("/forms/:form_id", rl(middleware.RateLimitTypeGuild, 30, time.Hour), api_forms.UpdateForm) + guildAuthApiAdmin.DELETE("/forms/:form_id", api_forms.DeleteForm) + guildAuthApiAdmin.POST("/forms/:form_id", api_forms.CreateInput) + guildAuthApiAdmin.PATCH("/forms/:form_id/:input_id", api_forms.UpdateInput) + guildAuthApiAdmin.DELETE("/forms/:form_id/:input_id", api_forms.DeleteInput) + // Should be a GET, but easier to take a body for development purposes guildAuthApiSupport.POST("/transcripts", rl(middleware.RateLimitTypeUser, 5, 5*time.Second), diff --git a/frontend/src/components/form/Dropdown.svelte b/frontend/src/components/form/Dropdown.svelte index 272749c..de0187e 100644 --- a/frontend/src/components/form/Dropdown.svelte +++ b/frontend/src/components/form/Dropdown.svelte @@ -1,5 +1,7 @@