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 @@
-
+
{#if icon !== undefined}
{/if}
-
-
-
+ {#if !iconOnly}
+
+
+
+ {/if}
\ 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 @@
+
+
+
+ Embed Builder
+
+
+
+
+
+
+ dispatch("cancel", {})}>Cancel
+ dispatch("confirm", {})}>
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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}
+
+
+
+
+
+ {data.description}
+
+
+
+
+ {/if}
+
+ {#if !editingMetadata || editMode}
+
+
+
+ HTTP Request
+
+
+
API Endpoint
+
+
When a user opens a ticket, a HTTP {data.http_method}
request will be sent to the
+ provided
+ request
+ URL. The URL must respond with a valid JSON payload.
+
+
+
+
+
+
Secrets
+
+
+ If creating a public integration, you may wish to let users provide secret values, e.g. API keys,
+ instead of sending all requests through your own.
+
+
+
Note: Do not include the %
symbols in secret names, they will be automatically
+ included
+
+
+
+ {#each data.secrets as secret, i}
+
+ {#if i === 0}
+
+
+ deleteSecret(i)}/>
+
+ {:else}
+
+
+ deleteSecret(i)}/>
+
+ {/if}
+
+ {/each}
+
+
+
= 5}>
+ Add Additional Secret
+
+
+
+
+
+
Request Headers
+
+
You can specify up to 5 HTTP headers that will be sent with the request, for example, containing
+ authentication
+ keys. You may specify the user's ID in a header, via %user_id%
.
+
+
+
+ You may also include the values of secrets you have created, via %secret_name%
.
+ {#if data.secrets.length > 0}
+ For example, %{data.secrets[0].name}%
.
+ {/if}
+
+
+
+ {#each data.headers as header, i}
+
+ {/each}
+
+
+
= 5}>
+ Add Additional Header
+
+
+
+
+
+ {#if editMode}
+ Save
+ {:else }
+ Create
+ {/if}
+
+
+
+
+
+ Placeholders
+
+
+
+ The response must contain a valid JSON payload. This payload will be parsed, and values can be
+ extracted
+ to use as placeholders in your welcome message.
+
+
+
+ Do not include the % symbols in the placeholder names. They will be included automatically.
+
+
+
+ The JSON path is the key path to access a field in the response JSON. You can use a period
+ (e.g. user.username
) to access nested objects.
+ You will be presented with an example JSON payload as you type.
+
+
+
+ {#each data.placeholders as placeholder, i}
+
+ {/each}
+
+
+
= 15}>
+ Add Additional Placeholder
+
+
+
+
+
Example Response
+
+
The request must be responded to with a JSON payload in the following form:
+
+ {exampleJson}
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/components/NavElement.svelte b/frontend/src/components/NavElement.svelte
index 98efaba..7f6c0ba 100644
--- a/frontend/src/components/NavElement.svelte
+++ b/frontend/src/components/NavElement.svelte
@@ -26,17 +26,24 @@
padding: 20px 0 20px 15px;
}
+ .row {
+ display: flex;
+ flex-direction: row;
+ }
+
:global(.link) {
display: flex;
color: inherit;
text-decoration: none;
cursor: pointer;
+ text-align: center;
}
:global(.link-blue) {
color: #3472f7;
text-decoration: none;
cursor: pointer;
+ text-align: center;
}
.icon {
diff --git a/frontend/src/components/manage/Integration.svelte b/frontend/src/components/manage/Integration.svelte
new file mode 100644
index 0000000..7928ad5
--- /dev/null
+++ b/frontend/src/components/manage/Integration.svelte
@@ -0,0 +1,141 @@
+
+ {#if imageUrl !== null}
+
+ {:else}
+
+ {/if}
+
+
+
{name}
+ {#if builtIn}
+
Built-In
+ {/if}
+ {#if added}
+
Active
+ {/if}
+ {#if guildCount !== undefined}
+
+
+
+ {guildCount}
+
+
+ {/if}
+
+
+
+
+
+ {#if !hideLinks}
+
+ {#if builtIn}
+
View
+ {:else if added}
+
View
+
Configure
+
dispatch("remove", {})}>Remove
+ {:else}
+ {#if owned}
+
Preview
+
Configure
+
+ {:else}
+
View
+ {/if}
+
Add to server
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/includes/Navbar.svelte b/frontend/src/includes/Navbar.svelte
index ac1982c..dd01d9f 100644
--- a/frontend/src/includes/Navbar.svelte
+++ b/frontend/src/includes/Navbar.svelte
@@ -8,15 +8,23 @@
{#if isAdmin}
- Settings
+ Settings
+
{/if}
- Transcripts
+ Transcripts
+
{#if isAdmin}
- Reaction Panels
- Forms
- Staff Teams
+ Reaction Panels
+ Forms
+ Staff Teams
+
+
+ Integrations
+ New!
+
+
{/if}
Tickets
@@ -24,7 +32,7 @@
Tags
{#if isAdmin}
- Customise Appearance
+ Appearance
{/if}
@@ -39,6 +47,7 @@
diff --git a/frontend/src/routes.js b/frontend/src/routes.js
index c52610a..59b09ab 100644
--- a/frontend/src/routes.js
+++ b/frontend/src/routes.js
@@ -24,6 +24,12 @@ import Appearance from './views/Appearance.svelte';
import Forms from './views/Forms.svelte';
import StaffOverride from './views/StaffOverride.svelte';
import BotStaff from './views/admin/BotStaff.svelte';
+import Integrations from "./views/integrations/Integrations.svelte";
+import IntegrationView from "./views/integrations/View.svelte";
+import IntegrationCreate from "./views/integrations/Create.svelte";
+import IntegrationConfigure from "./views/integrations/Configure.svelte";
+import IntegrationActivate from "./views/integrations/Activate.svelte";
+import IntegrationManage from "./views/integrations/Manage.svelte";
export const routes = [
{name: '/', component: Index, layout: IndexLayout},
@@ -92,6 +98,41 @@ export const routes = [
}
]
},
+ {
+ name: 'integrations',
+ nestedRoutes: [
+ {
+ name: 'index',
+ component: Integrations,
+ layout: ManageLayout,
+ },
+ {
+ name: 'create',
+ component: IntegrationCreate,
+ layout: ManageLayout,
+ },
+ {
+ name: '/view/:integration',
+ component: IntegrationView,
+ layout: ManageLayout,
+ },
+ {
+ name: '/configure/:integration',
+ component: IntegrationConfigure,
+ layout: ManageLayout,
+ },
+ {
+ name: '/activate/:integration',
+ component: IntegrationActivate,
+ layout: ManageLayout,
+ },
+ {
+ name: '/manage/:integration',
+ component: IntegrationManage,
+ layout: ManageLayout,
+ },
+ ]
+ }
],
}
]
\ No newline at end of file
diff --git a/frontend/src/views/integrations/Activate.svelte b/frontend/src/views/integrations/Activate.svelte
new file mode 100644
index 0000000..7e0e7aa
--- /dev/null
+++ b/frontend/src/views/integrations/Activate.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+ Add {integration.name} To Your Server
+
+
Secrets
+ {#if integration.secrets !== undefined}
+ {#if integration.secrets.length === 0}
+
This integration does not require any secrets.
+ {:else}
+
This integration requires you to provide some secrets. These will be sent to the server controlled by
+ the creator of {integration.name}, at: {integration.webhook_url}
+
Note, the integration creator may change the server at any time.
+
+
+ {#each integration.secrets as secret}
+
+
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+ Add to server
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/integrations/Configure.svelte b/frontend/src/views/integrations/Configure.svelte
new file mode 100644
index 0000000..5c60185
--- /dev/null
+++ b/frontend/src/views/integrations/Configure.svelte
@@ -0,0 +1,71 @@
+{#if data !== undefined}
+ editIntegration(e.detail)} on:makePublic={(e) => makePublic(e.detail)}/>
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/views/integrations/Create.svelte b/frontend/src/views/integrations/Create.svelte
new file mode 100644
index 0000000..d8bef19
--- /dev/null
+++ b/frontend/src/views/integrations/Create.svelte
@@ -0,0 +1,27 @@
+ createIntegration(e.detail)} />
+
+
\ No newline at end of file
diff --git a/frontend/src/views/integrations/Integrations.svelte b/frontend/src/views/integrations/Integrations.svelte
new file mode 100644
index 0000000..45d2f44
--- /dev/null
+++ b/frontend/src/views/integrations/Integrations.svelte
@@ -0,0 +1,249 @@
+
+
+
+
+
My Integrations
+ navigateTo(`/manage/${guildId}/integrations/create`)}>Create
+ Integration
+
+
+
+ {#each ownedIntegrations as integration}
+
+
+
+ {integration.description}
+
+
+
+ {/each}
+
+
+
+
+
Available Integrations
+
+
+ {#if page === 1}
+
+
+
+ Our Bloxlink integration inserts the Roblox usernames, profile URLs and more of your users into
+ ticket welcome messages automatically! This integration is automatically enabled in all servers, press the
+ View button below to check out the full list of placeholders you can use!
+
+
+
+ {/if}
+
+ {#each availableIntegrations as integration}
+
+ removeIntegration(integration.id)}>
+
+ {integration.description}
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/integrations/Manage.svelte b/frontend/src/views/integrations/Manage.svelte
new file mode 100644
index 0000000..0486a71
--- /dev/null
+++ b/frontend/src/views/integrations/Manage.svelte
@@ -0,0 +1,174 @@
+
+
+
+
+ Edit {integration.name} Settings
+
+
Secrets
+ You cannot view previously submitted secrets for security reasons. The secret fields will show as empty,
+ even if you have previously submitted them.
+
+ {#if integration.secrets !== undefined}
+ {#if integration.secrets.length === 0}
+
This integration does not require any secrets.
+ {:else}
+
This integration requires you to provide some secrets. These will be sent to the server controlled by
+ the creator of {integration.name}, at: {integration.webhook_url}
+
Note, the integration creator may change the server at any time.
+
+
+ {#each integration.secrets as secret}
+
+
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+ Remove from server
+ Save Integration
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/integrations/View.svelte b/frontend/src/views/integrations/View.svelte
new file mode 100644
index 0000000..f104e28
--- /dev/null
+++ b/frontend/src/views/integrations/View.svelte
@@ -0,0 +1,174 @@
+
+
+
+
+ About {integration.name}
+
+
{integration.description}
+
+
When a user opens a ticket, a request containing the ticket opener's user ID will
+ be sent to the following URL, controlled by the integration author:
+
+
+
+ {#if privacy_policy_url === null}
+
The integration author has not provided a privacy policy.
+ {:else}
+
The integration author has provided a privacy policy, accessible at
+ {privacy_policy_url}
+
+ {/if}
+
+
+ {#if isActive}
+ Remove from server
+ {:else}
+ navigateTo(`/manage/${guildId}/integrations/activate/${integrationId}`)}>
+ Add to server
+
+ {/if}
+
+
+
+
+
+ Placeholders
+
+
The following placeholders are available to user in welcome messages through the {integration.name}
+ integration:
+
+
+ {#if integration.placeholders}
+ {#each integration.placeholders as placeholder}
+ %{placeholder.name}%
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
+
+
diff --git a/go.mod b/go.mod
index e6812f2..0262b89 100644
--- a/go.mod
+++ b/go.mod
@@ -5,10 +5,10 @@ go 1.18
require (
github.com/BurntSushi/toml v0.3.1
github.com/TicketsBot/archiverclient v0.0.0-20220326163414-558fd52746dc
- github.com/TicketsBot/common v0.0.0-20220615205931-a6a31e73b52a
- github.com/TicketsBot/database v0.0.0-20220627203133-dd19fe34f094
+ github.com/TicketsBot/common v0.0.0-20220703211704-f792aa9f0c42
+ github.com/TicketsBot/database v0.0.0-20220710120946-a97157c1ca8c
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c
- github.com/TicketsBot/worker v0.0.0-20220627203254-f37bdb40b39a
+ github.com/TicketsBot/worker v0.0.0-20220710121124-cd5ec72739f9
github.com/apex/log v1.1.2
github.com/getsentry/sentry-go v0.13.0
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
@@ -19,12 +19,13 @@ require (
github.com/go-redis/redis/v8 v8.11.3
github.com/go-redis/redis_rate/v9 v9.1.1
github.com/golang-jwt/jwt v3.2.2+incompatible
+ github.com/google/uuid v1.1.1
github.com/gorilla/websocket v1.5.0
github.com/jackc/pgtype v1.4.0
github.com/jackc/pgx/v4 v4.7.1
github.com/pasztorpisti/qs v0.0.0-20171216220353-8d6c33ee906c
github.com/pkg/errors v0.9.1
- github.com/rxdn/gdl v0.0.0-20220621165443-28e214d254c1
+ github.com/rxdn/gdl v0.0.0-20220702190021-560b2ab99d25
github.com/sirupsen/logrus v1.5.0
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
)
diff --git a/go.sum b/go.sum
index 6ce0392..11b946f 100644
--- a/go.sum
+++ b/go.sum
@@ -5,14 +5,17 @@ github.com/TicketsBot/archiverclient v0.0.0-20220326163414-558fd52746dc h1:n15W8
github.com/TicketsBot/archiverclient v0.0.0-20220326163414-558fd52746dc/go.mod h1:2KcfHS0JnSsgcxZBs3NyWMXNQzEo67mBSGOyzHPWOCc=
github.com/TicketsBot/common v0.0.0-20220615205931-a6a31e73b52a h1:SwA18cDURmnXSrKBdetNVanSsyJBMtyosDzvgYMpKP4=
github.com/TicketsBot/common v0.0.0-20220615205931-a6a31e73b52a/go.mod h1:ZAoYcDD7SQLTsZT7dbo/X0J256+pogVRAReunCGng+U=
-github.com/TicketsBot/database v0.0.0-20220627203133-dd19fe34f094 h1:a272Wj4At5XjIjLoRAdn4PwfZ288+A4QzwTvFAR6fho=
-github.com/TicketsBot/database v0.0.0-20220627203133-dd19fe34f094/go.mod h1:F57cywrZsnper1cy56Bx0c/HEsxQBLHz3Pl98WXblWw=
+github.com/TicketsBot/common v0.0.0-20220703211704-f792aa9f0c42/go.mod h1:WxHh6bY7KhIqdayeOp5f0Zj2NNi/7QqCQfMEqHnpdAM=
+github.com/TicketsBot/database v0.0.0-20220710120946-a97157c1ca8c h1:WD3W0cUkIpMI5XUoGDQ88uYJnljZXxqv0e9TWCt6MqI=
+github.com/TicketsBot/database v0.0.0-20220710120946-a97157c1ca8c/go.mod h1:F57cywrZsnper1cy56Bx0c/HEsxQBLHz3Pl98WXblWw=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c=
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261/go.mod h1:2zPxDAN2TAPpxUPjxszjs3QFKreKrQh5al/R3cMXmYk=
github.com/TicketsBot/worker v0.0.0-20220627203254-f37bdb40b39a h1:cx2YtngcJ7KpOBm/QotWKHb2XZbvUDJwD9azBl31e/k=
github.com/TicketsBot/worker v0.0.0-20220627203254-f37bdb40b39a/go.mod h1:R70+F86Z+UlretKMxOX1jRqCwvVlvuem9UAyAL4EiG8=
+github.com/TicketsBot/worker v0.0.0-20220710121124-cd5ec72739f9 h1:kxjeQ0OtHMh55y0xXuPmq1F24pAuH8V6Y4txk0ZJkAM=
+github.com/TicketsBot/worker v0.0.0-20220710121124-cd5ec72739f9/go.mod h1:QOawjBdVtnwfMkIaCCjYw/WleX2Rf5asNHk4OdbRJfs=
github.com/apex/log v1.1.2 h1:bnDuVoi+o98wOdVqfEzNDlY0tcmBia7r4YkjS9EqGYk=
github.com/apex/log v1.1.2/go.mod h1:SyfRweFO+TlkIJ3DVizTSeI1xk7jOIIqOnUPZQTTsww=
github.com/apex/logs v0.0.3/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
@@ -118,6 +121,7 @@ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@@ -273,6 +277,8 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rxdn/gdl v0.0.0-20220621165443-28e214d254c1 h1:1/q9ohADLOrMQwhTfxRplPiMQ6EmVnJB+pWb+SLHd0c=
github.com/rxdn/gdl v0.0.0-20220621165443-28e214d254c1/go.mod h1:HtxfLp4OaoPoDJHQ4JOx/QeLH2d40VgT3wNOf7ETsRE=
+github.com/rxdn/gdl v0.0.0-20220702190021-560b2ab99d25 h1:9G1HcBG9hLFT7+FKiiVECF+Z2Y0yt3BrSNnuDXcRb7A=
+github.com/rxdn/gdl v0.0.0-20220702190021-560b2ab99d25/go.mod h1:HtxfLp4OaoPoDJHQ4JOx/QeLH2d40VgT3wNOf7ETsRE=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.8.2 h1:2kZJwZCpb+E/V79kGO7daeq+hUwUJW0A5QD1Wv455dA=
github.com/schollz/progressbar/v3 v3.8.2/go.mod h1:9KHLdyuXczIsyStQwzvW8xiELskmX7fQMaZdN23nAv8=
diff --git a/utils/imageproxy.go b/utils/imageproxy.go
new file mode 100644
index 0000000..0f3f5db
--- /dev/null
+++ b/utils/imageproxy.go
@@ -0,0 +1,19 @@
+package utils
+
+import (
+ "github.com/TicketsBot/GoPanel/config"
+ "github.com/golang-jwt/jwt"
+ "github.com/google/uuid"
+ "strconv"
+ "time"
+)
+
+func GenerateImageProxyToken(imageUrl string) (string, error) {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+ "url": imageUrl,
+ "request_id": uuid.New().String(),
+ "exp": strconv.FormatInt(time.Now().Add(time.Second*30).Unix(), 10),
+ })
+
+ return token.SignedString([]byte(config.Conf.Bot.ImageProxySecret))
+}
diff --git a/utils/netutils.go b/utils/netutils.go
new file mode 100644
index 0000000..1f13e55
--- /dev/null
+++ b/utils/netutils.go
@@ -0,0 +1,12 @@
+package utils
+
+import "net/url"
+
+func GetUrlHost(rawUrl string) string {
+ parsed, err := url.Parse(rawUrl)
+ if err != nil {
+ return "Invalid URL"
+ }
+
+ return parsed.Hostname()
+}
diff --git a/utils/utils.go b/utils/utils.go
index 4386137..ffac9ba 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -11,3 +11,7 @@ func ValueOrZero[T any](v *T) T {
return *v
}
}
+
+func Slice[T any](v ...T) []T {
+ return v
+}