diff --git a/app/http/endpoints/api/integrations/activateintegration.go b/app/http/endpoints/api/integrations/activateintegration.go new file mode 100644 index 0000000..ea85f59 --- /dev/null +++ b/app/http/endpoints/api/integrations/activateintegration.go @@ -0,0 +1,96 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +type activateIntegrationBody struct { + Secrets map[string]string `json:"secrets"` +} + +func ActivateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + guildId := ctx.Keys["guildid"].(uint64) + + activeCount, err := dbclient.Client.CustomIntegrationGuilds.GetGuildIntegrationCount(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if activeCount >= 5 { + ctx.JSON(400, utils.ErrorStr("You can only have 5 integrations active at once")) + return + } + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + var data activateIntegrationBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + // Check the integration is public or the user created it + canActivate, err := dbclient.Client.CustomIntegrationGuilds.CanActivate(integrationId, userId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !canActivate { + ctx.JSON(403, utils.ErrorStr("You do not have permission to activate this integration")) + return + } + + // Check the secret values are valid + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if len(secrets) != len(data.Secrets) { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + + // Since we've checked the length, we can just iterate over the secrets and they're guaranteed to be correct + secretMap := make(map[int]string) + for secretName, value := range data.Secrets { + if len(value) == 0 || len(value) > 255 { + ctx.JSON(400, utils.ErrorStr("Secret values must be between 1 and 255 characters")) + return + } + + found := false + + inner: + for _, secret := range secrets { + if secret.Name == secretName { + found = true + secretMap[secret.Id] = value + break inner + } + } + + if !found { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + } + + if err := dbclient.Client.CustomIntegrationGuilds.AddToGuildWithSecrets(integrationId, guildId, secretMap); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/createintegration.go b/app/http/endpoints/api/integrations/createintegration.go new file mode 100644 index 0000000..f75094b --- /dev/null +++ b/app/http/endpoints/api/integrations/createintegration.go @@ -0,0 +1,130 @@ +package api + +import ( + "fmt" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "strings" +) + +type integrationCreateBody struct { + Name string `json:"name" validate:"required,min=1,max=32"` + Description string `json:"description" validate:"required,min=1,max=255"` + ImageUrl *string `json:"image_url" validate:"omitempty,url,max=255,startswith=https://"` + PrivacyPolicyUrl *string `json:"privacy_policy_url" validate:"omitempty,url,max=255,startswith=https://"` + + Method string `json:"http_method" validate:"required,oneof=GET POST"` + WebhookUrl string `json:"webhook_url" validate:"required,url,max=255,startswith=https://"` + + Secrets []struct { + Name string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + } `json:"secrets" validate:"dive,omitempty,min=0,max=5"` + + Headers []struct { + Name string `json:"name" validate:"required,min=1,max=32,excludes= "` + Value string `json:"value" validate:"required,min=1,max=255"` + } `json:"headers" validate:"dive,omitempty,min=0,max=5"` + + Placeholders []struct { + Placeholder string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + JsonPath string `json:"json_path" validate:"required,min=1,max=255"` + } `json:"placeholders" validate:"dive,omitempty,min=0,max=15"` +} + +var validate = validator.New() + +func CreateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + ownedCount, err := dbclient.Client.CustomIntegrations.GetOwnedCount(userId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if ownedCount >= 5 { + ctx.JSON(403, utils.ErrorStr("You have reached the integration limit (5/5)")) + return + } + + var data integrationCreateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + 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 integration")) + return + } + + formatted := "Your input contained the following errors:" + for _, validationError := range validationErrors { + formatted += fmt.Sprintf("\n%s", validationError.Error()) + } + + formatted = strings.TrimSuffix(formatted, "\n") + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + + integration, err := dbclient.Client.CustomIntegrations.Create(userId, data.WebhookUrl, data.Method, data.Name, data.Description, data.ImageUrl, data.PrivacyPolicyUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Store secrets + if len(data.Secrets) > 0 { + secrets := make([]database.CustomIntegrationSecret, len(data.Secrets)) + for i, secret := range data.Secrets { + secrets[i] = database.CustomIntegrationSecret{ + Name: secret.Name, + } + } + + if _, err := dbclient.Client.CustomIntegrationSecrets.CreateOrUpdate(integration.Id, secrets); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Store headers + if len(data.Headers) > 0 { + headers := make([]database.CustomIntegrationHeader, len(data.Headers)) + for i, header := range data.Headers { + headers[i] = database.CustomIntegrationHeader{ + Name: header.Name, + Value: header.Value, + } + } + + if _, err := dbclient.Client.CustomIntegrationHeaders.CreateOrUpdate(integration.Id, headers); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Store placeholders + if len(data.Placeholders) > 0 { + placeholders := make([]database.CustomIntegrationPlaceholder, len(data.Placeholders)) + for i, placeholder := range data.Placeholders { + placeholders[i] = database.CustomIntegrationPlaceholder{ + Name: placeholder.Placeholder, + JsonPath: placeholder.JsonPath, + } + } + + if _, err := dbclient.Client.CustomIntegrationPlaceholders.Set(integration.Id, placeholders); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + ctx.JSON(200, integration) +} diff --git a/app/http/endpoints/api/integrations/deleteintegration.go b/app/http/endpoints/api/integrations/deleteintegration.go new file mode 100644 index 0000000..17f8337 --- /dev/null +++ b/app/http/endpoints/api/integrations/deleteintegration.go @@ -0,0 +1,42 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func DeleteIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to manage this integration + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to delete this integration")) + return + } + + if err := dbclient.Client.CustomIntegrations.Delete(integration.Id); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/editsecrets.go b/app/http/endpoints/api/integrations/editsecrets.go new file mode 100644 index 0000000..e23610e --- /dev/null +++ b/app/http/endpoints/api/integrations/editsecrets.go @@ -0,0 +1,79 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func UpdateIntegrationSecretsHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + // Check integration is active + active, err := dbclient.Client.CustomIntegrationGuilds.IsActive(integrationId, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !active { + ctx.JSON(400, utils.ErrorStr("Integration is not active")) + return + } + + var data activateIntegrationBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + // Check the secret values are valid + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if len(secrets) != len(data.Secrets) { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + + // Since we've checked the length, we can just iterate over the secrets and they're guaranteed to be correct + secretMap := make(map[int]string) + for secretName, value := range data.Secrets { + if len(value) == 0 || len(value) > 255 { + ctx.JSON(400, utils.ErrorStr("Secret values must be between 1 and 255 characters")) + return + } + + found := false + + inner: + for _, secret := range secrets { + if secret.Name == secretName { + found = true + secretMap[secret.Id] = value + break inner + } + } + + if !found { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + } + + if err := dbclient.Client.CustomIntegrationSecretValues.UpdateAll(guildId, integrationId, secretMap); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/getintegration.go b/app/http/endpoints/api/integrations/getintegration.go new file mode 100644 index 0000000..ce0258c --- /dev/null +++ b/app/http/endpoints/api/integrations/getintegration.go @@ -0,0 +1,101 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "strconv" +) + +type integrationResponse struct { + // Strip out the sensitive fields + Id int `json:"id"` + OwnerId uint64 `json:"owner_id"` + WebhookHost string `json:"webhook_url"` + Name string `json:"name"` + Description string `json:"description"` + ImageUrl *string `json:"image_url"` + ProxyToken *string `json:"proxy_token,omitempty"` + PrivacyPolicyUrl *string `json:"privacy_policy_url"` + Public bool `json:"public"` + Approved bool `json:"approved"` + + Placeholders []database.CustomIntegrationPlaceholder `json:"placeholders"` + Secrets []database.CustomIntegrationSecret `json:"secrets"` +} + +func GetIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to view this integration + if integration.OwnerId != userId && !(integration.Public && integration.Approved) { + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this integration")) + return + } + + placeholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if placeholders == nil { + placeholders = make([]database.CustomIntegrationPlaceholder, 0) + } + + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if secrets == nil { + secrets = make([]database.CustomIntegrationSecret, 0) + } + + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + ctx.JSON(200, integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + Placeholders: placeholders, + Secrets: secrets, + }) +} diff --git a/app/http/endpoints/api/integrations/getintegrationdetailed.go b/app/http/endpoints/api/integrations/getintegrationdetailed.go new file mode 100644 index 0000000..632eee6 --- /dev/null +++ b/app/http/endpoints/api/integrations/getintegrationdetailed.go @@ -0,0 +1,86 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "strconv" +) + +type detailedResponse struct { + database.CustomIntegration + Placeholders []database.CustomIntegrationPlaceholder `json:"placeholders"` + Headers []database.CustomIntegrationHeader `json:"headers"` + Secrets []database.CustomIntegrationSecret `json:"secrets"` +} + +func GetIntegrationDetailedHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to view this integration + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this integration")) + return + } + + // Get placeholders + placeholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if placeholders == nil { + placeholders = make([]database.CustomIntegrationPlaceholder, 0) + } + + // Get headers + headers, err := dbclient.Client.CustomIntegrationHeaders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if headers == nil { + headers = make([]database.CustomIntegrationHeader, 0) + } + + // Get secrets + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if secrets == nil { + secrets = make([]database.CustomIntegrationSecret, 0) + } + + ctx.JSON(200, detailedResponse{ + CustomIntegration: integration, + Placeholders: placeholders, + Headers: headers, + Secrets: secrets, + }) +} diff --git a/app/http/endpoints/api/integrations/isactive.go b/app/http/endpoints/api/integrations/isactive.go new file mode 100644 index 0000000..f21b2ae --- /dev/null +++ b/app/http/endpoints/api/integrations/isactive.go @@ -0,0 +1,28 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func IsIntegrationActiveHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + active, err := dbclient.Client.CustomIntegrationGuilds.IsActive(integrationId, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, gin.H{ + "active": active, + }) +} diff --git a/app/http/endpoints/api/integrations/listintegrations.go b/app/http/endpoints/api/integrations/listintegrations.go new file mode 100644 index 0000000..ab05808 --- /dev/null +++ b/app/http/endpoints/api/integrations/listintegrations.go @@ -0,0 +1,78 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +const pageLimit = 20 +const builtInCount = 1 + +type integrationWithMetadata struct { + integrationResponse + GuildCount int `json:"guild_count"` + Added bool `json:"added"` +} + +func ListIntegrationsHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + guildId := ctx.Keys["guildid"].(uint64) + + page, err := strconv.Atoi(ctx.Query("page")) + if err != nil || page <= 1 { + page = 1 + } + + page -= 1 + + limit := pageLimit + if page == 0 { + limit -= builtInCount + } + + availableIntegrations, err := dbclient.Client.CustomIntegrationGuilds.GetAvailableIntegrationsWithActive(guildId, userId, limit, page*pageLimit) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + integrations := make([]integrationWithMetadata, len(availableIntegrations)) + for i, integration := range availableIntegrations { + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + integrations[i] = integrationWithMetadata{ + integrationResponse: integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + }, + GuildCount: integration.GuildCount, + Added: integration.Active, + } + } + + // Don't serve null + if integrations == nil { + integrations = make([]integrationWithMetadata, 0) + } + + ctx.JSON(200, integrations) +} diff --git a/app/http/endpoints/api/integrations/ownedintegrations.go b/app/http/endpoints/api/integrations/ownedintegrations.go new file mode 100644 index 0000000..e413416 --- /dev/null +++ b/app/http/endpoints/api/integrations/ownedintegrations.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +type integrationWithCount struct { + integrationResponse + GuildCount int `json:"guild_count"` +} + +func GetOwnedIntegrationsHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + group, _ := errgroup.WithContext(context.Background()) + + var integrations []database.CustomIntegrationWithGuildCount + var placeholders map[int][]database.CustomIntegrationPlaceholder + + // Retrieve integrations + group.Go(func() (err error) { + integrations, err = dbclient.Client.CustomIntegrations.GetAllOwned(userId) + return + }) + + // Retrieve placeholders + group.Go(func() (err error) { + placeholders, err = dbclient.Client.CustomIntegrationPlaceholders.GetAllForOwnedIntegrations(userId) + return + }) + + if err := group.Wait(); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + res := make([]integrationWithCount, len(integrations)) + for i, integration := range integrations { + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + res[i] = integrationWithCount{ + integrationResponse: integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + Placeholders: placeholders[integration.Id], + }, + GuildCount: integration.GuildCount, + } + } + + ctx.JSON(200, res) +} diff --git a/app/http/endpoints/api/integrations/removeintegration.go b/app/http/endpoints/api/integrations/removeintegration.go new file mode 100644 index 0000000..36e8d28 --- /dev/null +++ b/app/http/endpoints/api/integrations/removeintegration.go @@ -0,0 +1,25 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func RemoveIntegrationHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + if err := dbclient.Client.CustomIntegrationGuilds.RemoveFromGuild(integrationId, guildId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/setpublic.go b/app/http/endpoints/api/integrations/setpublic.go new file mode 100644 index 0000000..c5fc1c0 --- /dev/null +++ b/app/http/endpoints/api/integrations/setpublic.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + "github.com/TicketsBot/GoPanel/botcontext" + "github.com/TicketsBot/GoPanel/config" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "github.com/rxdn/gdl/objects/channel/embed" + "github.com/rxdn/gdl/rest" + "strconv" +) + +func SetIntegrationPublicHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to manage this integration")) + return + } + + if integration.Public { + ctx.JSON(400, utils.ErrorStr("You have already requested to make this integration public")) + return + } + + e := embed.NewEmbed(). + SetTitle("Public Integration Request"). + SetColor(0xfcb97d). + AddField("Integration ID", strconv.Itoa(integration.Id), true). + AddField("Integration Name", integration.Name, true). + AddField("Integration URL", fmt.Sprintf("`%s`", integration.WebhookUrl), true). + AddField("Integration Owner", fmt.Sprintf("<@%d>", integration.OwnerId), true). + AddField("Integration Description", integration.Description, false) + + botCtx := botcontext.PublicContext() + _, err = rest.ExecuteWebhook( + config.Conf.Bot.PublicIntegrationRequestWebhookToken, + botCtx.RateLimiter, + config.Conf.Bot.PublicIntegrationRequestWebhookId, + true, + rest.WebhookBody{ + Embeds: utils.Slice(e), + }, + ) + + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if err := dbclient.Client.CustomIntegrations.SetPublic(integration.Id); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/updateintegration.go b/app/http/endpoints/api/integrations/updateintegration.go new file mode 100644 index 0000000..b577d7e --- /dev/null +++ b/app/http/endpoints/api/integrations/updateintegration.go @@ -0,0 +1,268 @@ +package api + +import ( + "fmt" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "strconv" + "strings" +) + +type integrationUpdateBody struct { + Name string `json:"name" validate:"required,min=1,max=32"` + Description string `json:"description" validate:"required,min=1,max=255"` + ImageUrl *string `json:"image_url" validate:"omitempty,url,max=255,startswith=https://"` + PrivacyPolicyUrl *string `json:"privacy_policy_url" validate:"omitempty,url,max=255,startswith=https://"` + + Method string `json:"http_method" validate:"required,oneof=GET POST"` + WebhookUrl string `json:"webhook_url" validate:"required,url,max=255,startswith=https://"` + + Secrets []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Name string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + } `json:"secrets" validate:"dive,omitempty,min=0,max=5"` + + Headers []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Name string `json:"name" validate:"required,min=1,max=32,excludes= "` + Value string `json:"value" validate:"required,min=1,max=255"` + } `json:"headers" validate:"dive,omitempty,min=0,max=5"` + + Placeholders []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Placeholder string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + JsonPath string `json:"json_path" validate:"required,min=1,max=255"` + } `json:"placeholders" validate:"dive,omitempty,min=0,max=15"` +} + +func UpdateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + var data integrationUpdateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + 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 integration")) + return + } + + formatted := "Your input contained the following errors:" + for _, validationError := range validationErrors { + formatted += fmt.Sprintf("\n%s", validationError.Error()) + } + + formatted = strings.TrimSuffix(formatted, "\n") + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not own this integration")) + return + } + + // Update integration metadata + err = dbclient.Client.CustomIntegrations.Update(database.CustomIntegration{ + Id: integration.Id, + OwnerId: integration.OwnerId, + HttpMethod: data.Method, + WebhookUrl: data.WebhookUrl, + Name: data.Name, + Description: data.Description, + ImageUrl: data.ImageUrl, + PrivacyPolicyUrl: data.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + }) + + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Store secrets + if !data.updateSecrets(ctx, integration.Id) { + return + } + + // Store headers + if !data.updateHeaders(ctx, integration.Id) { + return + } + + // Store placeholders + if !data.updatePlaceholders(ctx, integration.Id) { + return + } + + ctx.JSON(200, integration) +} + +func (b *integrationUpdateBody) updatePlaceholders(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingPlaceholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, placeholder := range b.Placeholders { + if placeholder.Id != 0 { + isValid := false + inner: + for _, existingPlaceholder := range existingPlaceholders { + if existingPlaceholder.Id == placeholder.Id { + if existingPlaceholder.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for placeholders")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for placeholders")) + return false + } + } + } + + placeholders := make([]database.CustomIntegrationPlaceholder, len(b.Placeholders)) + for i, placeholder := range b.Placeholders { + placeholders[i] = database.CustomIntegrationPlaceholder{ + Id: placeholder.Id, + Name: placeholder.Placeholder, + JsonPath: placeholder.JsonPath, + } + } + + if _, err := dbclient.Client.CustomIntegrationPlaceholders.Set(integrationId, placeholders); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} + +func (b *integrationUpdateBody) updateHeaders(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingHeaders, err := dbclient.Client.CustomIntegrationHeaders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, header := range b.Headers { + if header.Id != 0 { + isValid := false + + inner: + for _, existingHeader := range existingHeaders { + if existingHeader.Id == header.Id { + if existingHeader.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for headers")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for headers")) + return false + } + } + } + + headers := make([]database.CustomIntegrationHeader, len(b.Headers)) + for i, header := range b.Headers { + headers[i] = database.CustomIntegrationHeader{ + Id: header.Id, + Name: header.Name, + Value: header.Value, + } + } + + if _, err := dbclient.Client.CustomIntegrationHeaders.CreateOrUpdate(integrationId, headers); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} + +func (b *integrationUpdateBody) updateSecrets(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingSecrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, secret := range b.Secrets { + if secret.Id != 0 { + isValid := false + inner: + for _, existingSecret := range existingSecrets { + if existingSecret.Id == secret.Id { + if existingSecret.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for secrets")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for secrets")) + return false + } + } + } + + secrets := make([]database.CustomIntegrationSecret, len(b.Secrets)) + for i, secret := range b.Secrets { + secrets[i] = database.CustomIntegrationSecret{ + Id: secret.Id, + Name: secret.Name, + } + } + + if _, err := dbclient.Client.CustomIntegrationSecrets.CreateOrUpdate(integrationId, secrets); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} diff --git a/app/http/server.go b/app/http/server.go index 5d864a4..7daa7f7 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -7,6 +7,7 @@ import ( 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_integrations "github.com/TicketsBot/GoPanel/app/http/endpoints/api/integrations" api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel" api_settings "github.com/TicketsBot/GoPanel/app/http/endpoints/api/settings" api_override "github.com/TicketsBot/GoPanel/app/http/endpoints/api/staffoverride" @@ -67,6 +68,16 @@ func StartServer() { apiGroup := router.Group("/api", middleware.VerifyXTicketsHeader, middleware.AuthenticateToken) { apiGroup.GET("/session", api.SessionHandler) + + integrationGroup := apiGroup.Group("/integrations") + + integrationGroup.GET("/self", api_integrations.GetOwnedIntegrationsHandler) + integrationGroup.GET("/view/:integrationid", api_integrations.GetIntegrationHandler) + integrationGroup.GET("/view/:integrationid/detail", api_integrations.GetIntegrationDetailedHandler) + integrationGroup.POST("/:integrationid/public", api_integrations.SetIntegrationPublicHandler) + integrationGroup.PATCH("/:integrationid", api_integrations.UpdateIntegrationHandler) + integrationGroup.DELETE("/:integrationid", api_integrations.DeleteIntegrationHandler) + apiGroup.POST("/integrations", api_integrations.CreateIntegrationHandler) } guildAuthApiAdmin := apiGroup.Group("/:id", middleware.AuthenticateGuild(permission.Admin)) @@ -156,6 +167,12 @@ func StartServer() { guildAuthApiAdmin.GET("/staff-override", api_override.GetOverrideHandler) guildAuthApiAdmin.POST("/staff-override", api_override.CreateOverrideHandler) guildAuthApiAdmin.DELETE("/staff-override", api_override.DeleteOverrideHandler) + + guildAuthApiAdmin.GET("/integrations/available", api_integrations.ListIntegrationsHandler) + guildAuthApiAdmin.GET("/integrations/:integrationid", api_integrations.IsIntegrationActiveHandler) + guildAuthApiAdmin.POST("/integrations/:integrationid", api_integrations.ActivateIntegrationHandler) + guildAuthApiAdmin.PATCH("/integrations/:integrationid", api_integrations.UpdateIntegrationSecretsHandler) + guildAuthApiAdmin.DELETE("/integrations/:integrationid", api_integrations.RemoveIntegrationHandler) } userGroup := router.Group("/user", middleware.AuthenticateToken) diff --git a/botcontext/get.go b/botcontext/get.go index 45f2382..33091a2 100644 --- a/botcontext/get.go +++ b/botcontext/get.go @@ -14,25 +14,28 @@ func ContextForGuild(guildId uint64) (ctx BotContext, err error) { return } - var keyPrefix string - if isWhitelabel { res, err := dbclient.Client.Whitelabel.GetByBotId(whitelabelBotId) if err != nil { return ctx, err } - ctx.BotId = res.BotId - ctx.Token = res.Token - keyPrefix = fmt.Sprintf("ratelimiter:%d", whitelabelBotId) + rateLimiter := ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, fmt.Sprintf("ratelimiter:%d", whitelabelBotId)), 1) + + return BotContext{ + BotId: res.BotId, + Token: res.Token, + RateLimiter: rateLimiter, + }, nil } else { - ctx.BotId = config.Conf.Bot.Id - ctx.Token = config.Conf.Bot.Token - keyPrefix = "ratelimiter:public" + return PublicContext(), nil + } +} + +func PublicContext() BotContext { + return BotContext{ + BotId: config.Conf.Bot.Id, + Token: config.Conf.Bot.Token, + RateLimiter: ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, "ratelimiter:public"), 1), } - - // TODO: Large sharding buckets - ctx.RateLimiter = ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, keyPrefix), 1) - - return } diff --git a/config/config.go b/config/config.go index 4ae0f99..e0705fd 100644 --- a/config/config.go +++ b/config/config.go @@ -54,14 +54,17 @@ type ( } Bot struct { - Id uint64 - Token string - PremiumLookupProxyUrl string `toml:"premium-lookup-proxy-url"` - PremiumLookupProxyKey string `toml:"premium-lookup-proxy-key"` - ObjectStore string - AesKey string `toml:"aes-key"` - ProxyUrl string `toml:"discord-proxy-url"` - RenderServiceUrl string `toml:"render-service-url"` + Id uint64 + Token string + PremiumLookupProxyUrl string `toml:"premium-lookup-proxy-url"` + PremiumLookupProxyKey string `toml:"premium-lookup-proxy-key"` + ObjectStore string + AesKey string `toml:"aes-key"` + ProxyUrl string `toml:"discord-proxy-url"` + RenderServiceUrl string `toml:"render-service-url"` + ImageProxySecret string `toml:"image-proxy-secret"` + PublicIntegrationRequestWebhookId uint64 `toml:"public-integration-request-webhook-id"` + PublicIntegrationRequestWebhookToken string `toml:"public-integration-request-webhook-token"` } Redis struct { @@ -74,11 +77,6 @@ type ( Cache struct { Uri string } - - Referral struct { - Show bool - Link string - } ) var ( @@ -126,6 +124,7 @@ func fromEnvvar() { botId, _ := strconv.ParseUint(os.Getenv("BOT_ID"), 10, 64) redisPort, _ := strconv.Atoi(os.Getenv("REDIS_PORT")) redisThreads, _ := strconv.Atoi(os.Getenv("REDIS_THREADS")) + publicIntegrationRequestWebhookId, _ := strconv.ParseUint(os.Getenv("PUBLIC_INTEGRATION_REQUEST_WEBHOOK_ID"), 10, 64) Conf = Config{ Admins: admins, @@ -157,14 +156,17 @@ func fromEnvvar() { Uri: os.Getenv("DATABASE_URI"), }, Bot: Bot{ - Id: botId, - Token: os.Getenv("BOT_TOKEN"), - PremiumLookupProxyUrl: os.Getenv("PREMIUM_PROXY_URL"), - PremiumLookupProxyKey: os.Getenv("PREMIUM_PROXY_KEY"), - ObjectStore: os.Getenv("LOG_ARCHIVER_URL"), - AesKey: os.Getenv("LOG_AES_KEY"), - ProxyUrl: os.Getenv("DISCORD_PROXY_URL"), - RenderServiceUrl: os.Getenv("RENDER_SERVICE_URL"), + Id: botId, + Token: os.Getenv("BOT_TOKEN"), + PremiumLookupProxyUrl: os.Getenv("PREMIUM_PROXY_URL"), + PremiumLookupProxyKey: os.Getenv("PREMIUM_PROXY_KEY"), + ObjectStore: os.Getenv("LOG_ARCHIVER_URL"), + AesKey: os.Getenv("LOG_AES_KEY"), + ProxyUrl: os.Getenv("DISCORD_PROXY_URL"), + RenderServiceUrl: os.Getenv("RENDER_SERVICE_URL"), + ImageProxySecret: os.Getenv("IMAGE_PROXY_SECRET"), + PublicIntegrationRequestWebhookId: publicIntegrationRequestWebhookId, + PublicIntegrationRequestWebhookToken: os.Getenv("PUBLIC_INTEGRATION_REQUEST_WEBHOOK_TOKEN"), }, Redis: Redis{ Host: os.Getenv("REDIS_HOST"), diff --git a/database/database.go b/database/database.go index b4bb7fc..3d69de6 100644 --- a/database/database.go +++ b/database/database.go @@ -13,7 +13,8 @@ import ( var Client *database.Database func ConnectToDatabase() { - config, err := pgxpool.ParseConfig(config.Conf.Database.Uri); if err != nil { + config, err := pgxpool.ParseConfig(config.Conf.Database.Uri) + if err != nil { panic(err) } diff --git a/frontend/public/assets/img/grey.png b/frontend/public/assets/img/grey.png new file mode 100644 index 0000000..208219b Binary files /dev/null and b/frontend/public/assets/img/grey.png differ diff --git a/frontend/public/global.css b/frontend/public/global.css index 395ad58..ffd294f 100644 --- a/frontend/public/global.css +++ b/frontend/public/global.css @@ -24,7 +24,6 @@ body { margin: 0; padding: 0 !important; box-sizing: border-box; - /*font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;*/ } label { @@ -62,3 +61,7 @@ button:not(:disabled):active { button:focus { border-color: #666; } + +code { + color: #f9a8d4; +} diff --git a/frontend/src/components/Badge.svelte b/frontend/src/components/Badge.svelte index 2e14517..7bbb485 100644 --- a/frontend/src/components/Badge.svelte +++ b/frontend/src/components/Badge.svelte @@ -1,17 +1,19 @@ -
+
+ + \ No newline at end of file diff --git a/frontend/src/components/Button.svelte b/frontend/src/components/Button.svelte index c151a7f..bbd42c1 100644 --- a/frontend/src/components/Button.svelte +++ b/frontend/src/components/Button.svelte @@ -1,10 +1,12 @@ - \ No newline at end of file diff --git a/frontend/src/components/ConfirmationModal.svelte b/frontend/src/components/ConfirmationModal.svelte new file mode 100644 index 0000000..c291971 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.svelte @@ -0,0 +1,73 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/IntegrationEditor.svelte b/frontend/src/components/IntegrationEditor.svelte new file mode 100644 index 0000000..3b887aa --- /dev/null +++ b/frontend/src/components/IntegrationEditor.svelte @@ -0,0 +1,501 @@ +{#if deleteConfirmationOpen} + publicConfirmationOpen = false} + on:confirm={dispatchDelete}> + Are you sure you want to delete your integration {data.name}? + Delete + +{/if} + +{#if publicConfirmationOpen} + deleteConfirmationOpen = false} + on:confirm={dispatchMakePublic}> +

Are you sure you want to make your integration {data.name} public? Everyone will be able to + add it to their servers.

+ Confirm +
+{/if} + +
+
+ {#if editingMetadata || editMode} +
+