Integrations

This commit is contained in:
rxdn 2022-07-10 13:19:01 +01:00
parent 0a2eb1a130
commit 145ebf30ea
37 changed files with 2846 additions and 58 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -62,6 +62,9 @@ type (
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,
@ -165,6 +164,9 @@ func fromEnvvar() {
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"),

View File

@ -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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -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;
}

View File

@ -1,17 +1,19 @@
<div class="badge">
<div class="badge" style="--badge-background-color: {colour}">
<slot></slot>
</div>
<script>
export let colour = '#3472f7';
</script>
<style>
.badge {
display: flex;
align-items: center;
background-color: #3472f7;
background-color: var(--badge-background-color, #3472f7);
border-radius: 2px;
font-size: 14px;
padding: 0 4px;
margin-left: 4px;
margin-bottom: 5px;
}
</style>

View File

@ -1,10 +1,12 @@
<button on:click isTrigger="1" class:fullWidth class:danger {disabled} {type}>
<button on:click isTrigger="1" class:fullWidth class:danger class:iconOnly {disabled} {type}>
{#if icon !== undefined}
<i class="{icon}"></i>
{/if}
{#if !iconOnly}
<span class="content">
<slot/>
</span>
{/if}
</button>
<script>
@ -13,6 +15,7 @@
export let disabled = false;
export let type = "submit";
export let danger = false;
export let iconOnly = false;
</script>
<style>
@ -65,4 +68,9 @@
background-color: #c32232 !important;
border-color: #c32232 !important;
}
.iconOnly {
width: 40px;
height: 40px;
}
</style>

View File

@ -0,0 +1,73 @@
<div class="modal" transition:fade>
<div class="modal-wrapper">
<Card footer="{true}" footerRight="{true}" fill="{false}">
<span slot="title">Embed Builder</span>
<div slot="body" class="body-wrapper">
<slot name="body"></slot>
</div>
<div slot="footer" style="gap: 12px">
<Button danger={!isDangerous} on:click={() => dispatch("cancel", {})}>Cancel</Button>
<Button danger={isDangerous} {icon} on:click={() => dispatch("confirm", {})}>
<slot name="confirm"></slot>
</Button>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
<script>
import Card from "./Card.svelte";
import {fade} from "svelte/transition";
import {createEventDispatcher} from "svelte";
import Button from "./Button.svelte";
const dispatch = createEventDispatcher();
export let icon;
export let isDangerous = false;
</script>
<style>
.modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 60%;
margin: 10% auto auto auto;
}
@media only screen and (max-width: 1280px) {
.modal-wrapper {
width: 96%;
}
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 500;
background-color: #000;
opacity: .5;
}
</style>

View File

@ -0,0 +1,501 @@
{#if deleteConfirmationOpen}
<ConfirmationModal icon="fas fa-globe" isDangerous on:cancel={() => publicConfirmationOpen = false}
on:confirm={dispatchDelete}>
<span slot="body">Are you sure you want to delete your integration {data.name}?</span>
<span slot="confirm">Delete</span>
</ConfirmationModal>
{/if}
{#if publicConfirmationOpen}
<ConfirmationModal icon="fas fa-trash-icon" on:cancel={() => deleteConfirmationOpen = false}
on:confirm={dispatchMakePublic}>
<p slot="body">Are you sure you want to make your integration <i>{data.name}</i> public? Everyone will be able to
add it to their servers.</p>
<span slot="confirm">Confirm</span>
</ConfirmationModal>
{/if}
<div class="parent">
<div class="content">
{#if editingMetadata || editMode}
<div class="row outer-row" bind:this={metadataRow} transition:fade>
<div class="col-metadata">
<Card footer footerRight>
<span slot="title">Integration Metadata</span>
<div slot="body" class="body-wrapper">
<p>Let people know what your integration does. A preview will be generated as you type.</p>
<div class="row">
<Input col2 label="Name" placeholder="My Integration" bind:value={data.name}/>
</div>
<div class="row">
<Input col2 label="Image URL" placeholder="https://example.com/logo.png" bind:value={data.image_url}
on:change={ensureNullIfBlank}/>
<Input col2 label="Privacy Policy URL" placeholder="https://example.com/privacy"
bind:value={data.privacy_policy_url} on:change={ensureNullIfBlank}/>
</div>
<div class="row">
<Textarea col1 label="Description" placeholder="Let people know what your integration does"
bind:value={data.description}/>
</div>
</div>
<div slot="footer" style="gap: 12px">
{#if editMode}
<Button icon="fas fa-globe" disabled={data.public} on:click={confirmMakePublic}>Make Public</Button>
<Button danger icon="fas fa-trash-can" on:click={requestDeleteConfirmation}>
Delete Integration
</Button>
{:else}
<Button icon="fas fa-arrow-right" disabled={data.name.length === 0 || data.description.length === 0}
on:click={nextStage}>
Continue
</Button>
{/if}
</div>
</Card>
</div>
<div class="col-preview">
<div class="preview">
<Integration hideLinks name={data.name} imageUrl={data.image_url}>
<span slot="description">{data.description}</span>
</Integration>
</div>
</div>
</div>
{/if}
{#if !editingMetadata || editMode}
<div class="row outer-row" transition:fade>
<div class="col-left">
<Card footer footerRight>
<span slot="title">HTTP Request</span>
<div slot="body" class="body-wrapper">
<div>
<h3>API Endpoint</h3>
<div class="section">
<p>When a user opens a ticket, a HTTP <code>{data.http_method}</code> request will be sent to the
provided
request
URL. The URL must respond with a valid JSON payload.</p>
<div class="row">
<Dropdown col4 bind:value={data.http_method} label="Request Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
</Dropdown>
<div class="col-3-4">
<Input col1 label="Request URL" bind:value={data.webhook_url}
placeholder="https://api.example.com/users/find?discord=%user_id%"/>
</div>
</div>
</div>
</div>
<div>
<h3>Secrets</h3>
<div class="section">
<p>
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.
</p>
<p>Note: Do not include the <code>%</code> symbols in secret names, they will be automatically
included
</p>
<div class="col">
{#each data.secrets as secret, i}
<div class="row">
{#if i === 0}
<Input col1 label="Secret Name" placeholder="api_key" bind:value={secret.name}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deleteSecret(i)}/>
</div>
{:else}
<Input col1 placeholder="api_key" bind:value={secret.name}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deleteSecret(i)}/>
</div>
{/if}
</div>
{/each}
</div>
<Button fullWidth icon="fas fa-plus" on:click={addSecret} disabled={data.secrets.length >= 5}>
Add Additional Secret
</Button>
</div>
</div>
<div>
<h3>Request Headers</h3>
<div class="section">
<p>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 <code>%user_id%</code>.
</p>
<p>
You may also include the values of secrets you have created, via <code>%secret_name%</code>.
{#if data.secrets.length > 0}
For example, <code>%{data.secrets[0].name}%</code>.
{/if}
</p>
<div class="col">
{#each data.headers as header, i}
<div class="row">
{#if i === 0}
<Input col2 label="Header Name" placeholder="x-auth-key" bind:value={header.name}/>
<Input col2 label="Header Value" placeholder="super secret key" bind:value={header.value}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deleteHeader(i)}/>
</div>
{:else}
<Input col2 placeholder="x-auth-key" bind:value={header.name}/>
<Input col2 placeholder="super secret key" bind:value={header.value}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deleteHeader(i)}/>
</div>
{/if}
</div>
{/each}
</div>
<Button fullWidth icon="fas fa-plus" on:click={addHeader} disabled={data.headers.length >= 5}>
Add Additional Header
</Button>
</div>
</div>
</div>
<div slot="footer">
{#if editMode}
<Button icon="fas fa-floppy-disk" on:click={dispatchSubmit}>Save</Button>
{:else }
<Button icon="fas fa-floppy-disk" on:click={dispatchSubmit}>Create</Button>
{/if}
</div>
</Card>
</div>
<div class="col-right">
<Card footer={false} fill={false}>
<span slot="title">Placeholders</span>
<div slot="body" class="body-wrapper">
<div class="section">
<p>
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.
</p>
<p>
Do <b>not</b> include the % symbols in the placeholder names. They will be included automatically.
</p>
<p>
The JSON path is the key path to access a field in the response JSON. You can use a period
(e.g. <code>user.username</code>) to access nested objects.
You will be presented with an example JSON payload as you type.
</p>
<div class="col">
{#each data.placeholders as placeholder, i}
<div class="row">
{#if i === 0}
<Input col2 label="Placeholder" placeholder="ingame_username"
bind:value={placeholder.name}/>
<Input col2 label="JSON Path" placeholder="user.username" bind:value={placeholder.json_path}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deletePlaceholder(i)}/>
</div>
{:else}
<Input col2 placeholder="ingame_username" bind:value={placeholder.name}/>
<Input col2 placeholder="user.username" bind:value={placeholder.json_path}/>
<div class="button-anchor">
<Button danger iconOnly icon="fas fa-trash-can" on:click={() => deletePlaceholder(i)}/>
</div>
{/if}
</div>
{/each}
</div>
<Button fullWidth icon="fas fa-plus" on:click={addPlaceholder}
disabled={data.placeholders.length >= 15}>
Add Additional Placeholder
</Button>
</div>
<div>
<h3>Example Response</h3>
<div class="section">
<p>The request must be responded to with a JSON payload in the following form:</p>
<code class="codeblock">
{exampleJson}
</code>
</div>
</div>
</div>
</Card>
</div>
</div>
{/if}
</div>
</div>
<script>
import {fade} from 'svelte/transition';
import Card from "./Card.svelte";
import Button from "./Button.svelte";
import Dropdown from "./form/Dropdown.svelte";
import Input from "./form/Input.svelte";
import Integration from "./manage/Integration.svelte";
import Textarea from "./form/Textarea.svelte";
import {createEventDispatcher, onMount} from "svelte";
import ConfirmationModal from "./ConfirmationModal.svelte";
const dispatch = createEventDispatcher();
export let guildId;
let metadataRow;
let editingMetadata = true;
let exampleJson = "{}";
export let data = {
name: "",
description: "",
image_url: "",
privacy_policy_url: "",
http_method: "GET",
placeholders: [],
headers: [],
secrets: [],
};
export let editMode = false;
let deleteConfirmationOpen = false;
let publicConfirmationOpen = false;
function requestDeleteConfirmation() {
deleteConfirmationOpen = true;
}
function confirmMakePublic() {
publicConfirmationOpen = true;
}
// on:input uses the old value!
$: data.placeholders, normalisePlaceholders();
$: data.placeholders, updateExampleJson();
$: data.secrets, normaliseSecrets();
$: data.headers, normaliseHeaders();
$: data.name, data.name = data.name.substring(0, 32);
$: data.description, data.description = data.description.substring(0, 255);
function addPlaceholder() {
data.placeholders.push({name: "", json_path: ""});
data = data;
}
function deletePlaceholder(i) {
data.placeholders.splice(i, 1);
data = data;
}
function normalisePlaceholders() {
data.placeholders = data.placeholders.map((placeholder) => {
placeholder.name = placeholder.name.replaceAll(' ', '_').replaceAll('%', '');
return placeholder;
});
}
function addHeader() {
data.headers.push({name: "", value: ""});
data = data;
}
function deleteHeader(i) {
data.headers.splice(i, 1);
data = data;
}
function normaliseHeaders() {
data.headers = data.headers.map((header) => {
header.name = header.name.replaceAll(' ', '-');
return header;
});
}
function addSecret() {
data.secrets.push({name: ""});
data = data;
}
function deleteSecret(i) {
data.secrets.splice(i, 1);
data = data;
}
function normaliseSecrets() {
data.secrets = data.secrets.map((secret) => {
secret.name = secret.name.replaceAll(' ', '_').replaceAll('%', '');
return secret;
});
}
function nextStage() {
editingMetadata = false;
metadataRow.style.display = 'none';
}
function ensureNullIfBlank() {
if (data.image_url !== undefined && data.image_url !== null && data.image_url.length === 0) {
data.image_url = null;
}
if (data.privacy_policy_url !== undefined && data.privacy_policy_url !== null && data.privacy_policy_url.length === 0) {
data.privacy_policy_url = null;
}
}
function updateExampleJson() {
try {
let obj = {};
for (const placeholder of data.placeholders) {
let split = placeholder.json_path.split(".");
let current = obj;
for (const [index, part] of split.entries()) {
if (index === split.length - 1) {
current[part] = "...";
} else {
if (current[part]) {
current = current[part];
} else {
current[part] = {};
current = current[part];
}
}
}
}
exampleJson = JSON.stringify(obj, null, 2);
} catch (e) {
exampleJson = "Invalid JSON";
}
}
function dispatchSubmit() {
ensureNullIfBlank();
dispatch("submit", data);
}
function dispatchMakePublic() {
publicConfirmationOpen = false;
dispatch("makePublic", data);
}
function dispatchDelete() {
dispatch("delete", data);
}
onMount(() => {
updateExampleJson();
if (!editMode) {
addPlaceholder();
}
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
row-gap: 5vh;
width: 96%;
height: 100%;
margin-top: 30px;
padding-bottom: 5vh;
}
.body-wrapper {
display: flex;
flex-direction: column;
row-gap: 1vh;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.col-left, .col-right {
width: 50%;
}
.col-metadata {
display: flex;
flex: 1;
}
.col-preview {
display: flex;
width: 33%;
max-width: 33%;
}
.preview {
width: 100%;
}
.button-anchor {
align-self: flex-end;
margin-bottom: 8px;
}
.codeblock {
border-color: #2e3136;
background-color: #2e3136;
color: white;
outline: none;
border-radius: 4px;
padding: 8px 12px;
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
white-space: pre-wrap;
}
.section {
display: flex;
flex-direction: column;
row-gap: 1vh;
}
@media only screen and (max-width: 950px) {
.outer-row {
flex-direction: column;
align-items: center;
}
.col-left, .col-right {
width: 100%;
}
}
</style>

View File

@ -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 {

View File

@ -0,0 +1,141 @@
<div class="wrapper">
{#if imageUrl !== null}
<img src={imageUrl} class="logo" bind:this={logo} on:error={useDefaultLogo}/>
{:else}
<img src="/assets/img/grey.png" class="logo"/>
{/if}
<div class="details">
<div class="title-row">
<span class="title">{name}</span>
{#if builtIn}
<Badge colour="#0c8f43">Built-In</Badge>
{/if}
{#if added}
<Badge>Active</Badge>
{/if}
{#if guildCount !== undefined}
<Badge>
<div class="guild-count">
<i class="fas fa-server"></i>
{guildCount}
</div>
</Badge>
{/if}
</div>
<span class="description">
<slot name="description"></slot>
</span>
{#if !hideLinks}
<div class="links">
{#if builtIn}
<a href="{viewLink}" target="_blank" class="link-blue">View</a>
{:else if added}
<Navigate to="/manage/{guildId}/integrations/view/{integrationId}" styles="link-blue">View</Navigate>
<Navigate to="/manage/{guildId}/integrations/manage/{integrationId}" styles="link-blue">Configure</Navigate>
<a href="#" class="link-blue" on:click={() => dispatch("remove", {})}>Remove</a>
{:else}
{#if owned}
<Navigate to="/manage/{guildId}/integrations/view/{integrationId}" styles="link-blue">Preview</Navigate>
<Navigate to="/manage/{guildId}/integrations/configure/{integrationId}" styles="link-blue">Configure
</Navigate>
{:else}
<Navigate to="/manage/{guildId}/integrations/view/{integrationId}" styles="link-blue">View</Navigate>
{/if}
<Navigate to="/manage/{guildId}/integrations/activate/{integrationId}" styles="link-blue">Add to server
</Navigate>
{/if}
</div>
{/if}
</div>
</div>
<script>
import Badge from "../Badge.svelte";
import {Navigate} from "svelte-router-spa";
import {createEventDispatcher} from "svelte";
const dispatch = createEventDispatcher();
export let guildId;
export let integrationId;
export let name;
export let imageUrl;
export let owned = false;
export let guildCount;
export let added = false;
export let builtIn = false;
export let hideLinks = false;
export let viewLink;
let logo;
function useDefaultLogo() {
logo.src = "/assets/img/grey.png";
}
</script>
<style>
.wrapper {
display: flex;
flex-direction: column;
border-radius: 10px;
background-color: #272727 !important;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
transition: all .3s ease-in-out;
height: 100%;
}
.logo {
width: 100%;
border-radius: 10px 10px 0 0;
min-height: 150px;
height: 150px;
max-height: 150px;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
padding: 10px 20px 10px 20px;
height: 100%;
}
.title-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.title {
font-size: 18px;
font-weight: bolder;
word-break: break-all;
}
.description {
flex: 1;
font-size: 14px;
color: #b9bbbe;
}
.guild-count {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.links {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
</style>

View File

@ -8,15 +8,23 @@
<!-- on:click required to close dropdown again -->
{#if isAdmin}
<NavElement icon="fas fa-cogs" link="/manage/{guildId}/settings" on:click={closeDropdown}>Settings</NavElement>
<NavElement icon="fas fa-cogs" link="/manage/{guildId}/settings" on:click={closeDropdown}>Settings
</NavElement>
{/if}
<NavElement icon="fas fa-copy" link="/manage/{guildId}/transcripts" on:click={closeDropdown}>Transcripts</NavElement>
<NavElement icon="fas fa-copy" link="/manage/{guildId}/transcripts" on:click={closeDropdown}>Transcripts
</NavElement>
{#if isAdmin}
<NavElement icon="fas fa-mouse-pointer" link="/manage/{guildId}/panels" on:click={closeDropdown}>Reaction Panels</NavElement>
<NavElement icon="fas fa-poll-h" link="/manage/{guildId}/forms" on:click={closeDropdown}>Forms</NavElement>
<NavElement icon="fas fa-users" link="/manage/{guildId}/teams" on:click={closeDropdown}>Staff Teams</NavElement>
<NavElement icon="fas fa-robot" link="/manage/{guildId}/integrations" on:click={closeDropdown}>
<div style="display: flex; gap:4px">
Integrations
<Badge>New!</Badge>
</div>
</NavElement>
{/if}
<NavElement icon="fas fa-ticket-alt" link="/manage/{guildId}/tickets" on:click={closeDropdown}>Tickets</NavElement>
@ -24,7 +32,7 @@
<NavElement icon="fas fa-tags" link="/manage/{guildId}/tags" on:click={closeDropdown}>Tags</NavElement>
{#if isAdmin}
<NavElement icon="fas fa-paint-brush" link="/manage/{guildId}/appearance" on:click={closeDropdown}>Customise Appearance</NavElement>
<NavElement icon="fas fa-paint-brush" link="/manage/{guildId}/appearance" on:click={closeDropdown}>Appearance</NavElement>
{/if}
</div>
</div>
@ -39,6 +47,7 @@
<script>
import NavElement from "../components/NavElement.svelte";
import Badge from "../components/Badge.svelte";
export let guildId;
export let dropdown;

View File

@ -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,
},
]
}
],
}
]

View File

@ -0,0 +1,160 @@
<div class="parent">
<div class="content">
<div class="col-left">
<Card footer footerRight>
<span slot="title">Add {integration.name} To Your Server</span>
<div slot="body" class="body-wrapper">
<h3>Secrets</h3>
{#if integration.secrets !== undefined}
{#if integration.secrets.length === 0}
<p>This integration does not require any secrets.</p>
{:else}
<p>This integration requires you to provide some secrets. These will be sent to the server controlled by
the creator of {integration.name}, at: <code>{integration.webhook_url}</code></p>
<p>Note, the integration creator may change the server at any time.</p>
<div class="secret-container">
{#each integration.secrets as secret}
<div class="secret-input">
<Input col1 label="{secret.name}" placeholder="{secret.name}" bind:value={secretValues[secret.name]}/>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<div slot="footer">
<Button disabled={!allValuesFilled} on:click={activateIntegration}>Add to server</Button>
</div>
</Card>
</div>
</div>
</div>
<script>
import {notifyError, withLoadingScreen} from '../../js/util'
import axios from "axios";
import {API_URL} from "../../js/constants";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Card from "../../components/Card.svelte";
import Button from "../../components/Button.svelte";
import Input from "../../components/form/Input.svelte";
import {navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let integrationId = currentRoute.namedParams.integration;
let integration = {};
let secretValues = {};
let allValuesFilled = true;
$: secretValues, updateAllValuesFilled();
function updateAllValuesFilled() {
if (integration.secrets === undefined) {
return;
}
if (Object.keys(secretValues).length !== integration.secrets.length) {
allValuesFilled = false;
return;
}
for (let key in secretValues) {
if (secretValues[key] === '') {
allValuesFilled = false;
return;
}
}
allValuesFilled = true;
}
async function activateIntegration() {
let data = {
secrets: secretValues
};
let res = await axios.post(`${API_URL}/api/${guildId}/integrations/${integrationId}`, data);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/integrations?added=true`);
}
async function loadIntegration() {
let res = await axios.get(`${API_URL}/api/integrations/view/${integrationId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
integration = res.data;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await Promise.all([
loadIntegration()
]);
updateAllValuesFilled();
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: row;
justify-content: center;
width: 96%;
height: 100%;
margin-top: 30px;
padding-bottom: 5vh;
gap: 2%;
}
.col-left {
width: 60%;
}
.body-wrapper {
display: flex;
flex-direction: column;
row-gap: 1vh;
}
.secret-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2%;
}
.secret-input {
flex: 0 0 49%;
}
@media only screen and (max-width: 950px) {
.content {
flex-direction: column;
row-gap: 2vh;
}
.col-left {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,71 @@
{#if data !== undefined}
<IntegrationEditor editMode {guildId} {data} on:delete={deleteIntegration}
on:submit={(e) => editIntegration(e.detail)} on:makePublic={(e) => makePublic(e.detail)}/>
{/if}
<script>
import IntegrationEditor from "../../components/IntegrationEditor.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {notifyError, notifySuccess, withLoadingScreen} from "../../js/util";
import axios from "axios";
import {navigateTo} from "svelte-router-spa";
import {API_URL} from "../../js/constants";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let integrationId = currentRoute.namedParams.integration;
let freshlyCreated = currentRoute.queryParams.created === "true";
let data;
async function makePublic(data) {
const res = await axios.post(`${API_URL}/api/integrations/${integrationId}/public`, data);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
notifySuccess("Your request to make this integration public has been submitted! It will be reviewed over the next few days.");
data.public = true;
}
async function editIntegration(data) {
const res = await axios.patch(`${API_URL}/api/integrations/${integrationId}`, data);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess("Integration updated");
await loadIntegration();
}
async function deleteIntegration() {
const res = await axios.delete(`${API_URL}/api/integrations/${integrationId}`);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/integrations`);
}
async function loadIntegration() {
const res = await axios.get(`${API_URL}/api/integrations/view/${integrationId}/detail`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
data = res.data;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await loadIntegration();
if (freshlyCreated) {
notifySuccess("Your integration has been created successfully!");
}
});
</script>

View File

@ -0,0 +1,27 @@
<IntegrationEditor {guildId} on:submit={(e) => createIntegration(e.detail)} />
<script>
import IntegrationEditor from "../../components/IntegrationEditor.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {notifyError, withLoadingScreen} from "../../js/util";
import axios from "axios";
import {navigateTo} from "svelte-router-spa";
import {API_URL} from "../../js/constants";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
async function createIntegration(data) {
const res = await axios.post(`${API_URL}/api/integrations`, data);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/integrations/configure/${res.data.id}?created=true`);
}
withLoadingScreen(async () => {
setDefaultHeaders();
});
</script>

View File

@ -0,0 +1,249 @@
<div class="parent">
<div class="content">
<div class="container">
<div class="spread">
<h4 class="title">My Integrations</h4>
<Button icon="fas fa-server" on:click={() => navigateTo(`/manage/${guildId}/integrations/create`)}>Create
Integration
</Button>
</div>
<div class="integrations my-integrations">
{#each ownedIntegrations as integration}
<div class="integration">
<Integration owned name={integration.name} {guildId} integrationId={integration.id}
imageUrl={generateProxyUrl(integration)} guildCount={integration.guild_count}>
<span slot="description">
{integration.description}
</span>
</Integration>
</div>
{/each}
</div>
</div>
<div>
<h4 class="title">Available Integrations</h4>
<div class="integrations">
<!-- Built in -->
{#if page === 1}
<div class="integration">
<Integration builtIn name="Bloxlink"
imageUrl="https://dbl-static.b-cdn.net/9bbd1f9504ddefc89606b19b290e9a0f.png"
viewLink="https://docs.ticketsbot.net/setup/placeholders#bloxlink">
<span slot="description">
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!
</span>
</Integration>
</div>
{/if}
{#each availableIntegrations as integration}
<div class="integration">
<Integration name={integration.name} {guildId} integrationId={integration.id} imageUrl={generateProxyUrl(integration)}
added={integration.added} guildCount={integration.guild_count} on:remove={() => removeIntegration(integration.id)}>
<span slot="description">
{integration.description}
</span>
</Integration>
</div>
{/each}
</div>
</div>
<div class="pagination">
<i class="fas fa-chevron-left pagination-chevron" class:disabled-chevron={page === 1} on:click={previousPage}></i>
<p>Page {page}</p>
<i class="fas fa-chevron-right pagination-chevron" class:disabled-chevron={!hasNextPage} on:click={nextPage}></i>
</div>
</div>
</div>
<script>
import {notifyError, notifySuccess, withLoadingScreen} from '../../js/util'
import axios from "axios";
import {API_URL} from "../../js/constants";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Integration from "../../components/manage/Integration.svelte";
import Button from "../../components/Button.svelte";
import {navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let freshlyRemoved = currentRoute.queryParams.removed === "true";
let freshlyAdded = currentRoute.queryParams.added === "true";
let ownedIntegrations = [];
let availableIntegrations = [];
const pageLimit = 20;
const builtInIntegrationCount = 1;
let page = 1;
function previousPage() {
if (page > 1) {
page--;
loadAvailableIntegrations();
}
}
function nextPage() {
if (hasNextPage) {
page++;
loadAvailableIntegrations();
}
}
let hasNextPage = true;
$: if (page === 1) {
hasNextPage = availableIntegrations.length + builtInIntegrationCount >= pageLimit;
} else {
hasNextPage = availableIntegrations.length >= pageLimit;
}
function generateProxyUrl(integration) {
if (integration.image_url === null || integration.proxy_token === undefined || integration.proxy_token === null) {
return null;
}
return `https://image-cdn.ticketsbot.net/proxy?token=${integration.proxy_token}`
}
async function removeIntegration(integrationId) {
const res = await axios.delete(`${API_URL}/api/${guildId}/integrations/${integrationId}`);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
await loadAvailableIntegrations();
notifySuccess("Integration removed from server successfully");
}
async function loadAvailableIntegrations() {
const res = await axios.get(`${API_URL}/api/${guildId}/integrations/available?page=${page}`);
if (res.status !== 200) {
notifyError(res.data.error);
return
}
availableIntegrations = res.data;
}
async function loadOwnedIntegrations() {
let res = await axios.get(`${API_URL}/api/integrations/self`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
ownedIntegrations = res.data;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await Promise.all([
loadOwnedIntegrations(),
loadAvailableIntegrations()
]);
if (freshlyAdded) {
notifySuccess("Integration added to server successfully!");
} else if (freshlyRemoved) {
notifySuccess("Integration removed from server successfully!");
}
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 96%;
height: 100%;
margin-top: 30px;
padding-bottom: 5vh;
row-gap: 4vh;
}
.container {
display: flex;
flex-direction: column;
}
.integrations {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2%;
row-gap: 4vh;
}
.integration {
flex: 0 0 23.5%;
}
.my-integrations {
margin-top: 2vh;
}
.title {
color: white;
font-size: 22px;
font-weight: bolder;
}
.spread {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 5px;
}
.pagination-chevron {
cursor: pointer;
color: #3472f7;
}
.disabled-chevron {
color: #777 !important;
cursor: default !important;
}
@media only screen and (max-width: 1180px) {
.integration {
flex: 0 0 32%;
}
}
@media only screen and (max-width: 930px) {
.integration {
flex: 0 0 49%;
}
}
@media only screen and (max-width: 576px) {
.integration {
flex: 0 0 100%;
}
}
</style>

View File

@ -0,0 +1,174 @@
<div class="parent">
<div class="content">
<div class="col-left">
<Card footer footerRight>
<span slot="title">Edit {integration.name} Settings</span>
<div slot="body" class="body-wrapper">
<h3>Secrets</h3>
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}
<p>This integration does not require any secrets.</p>
{:else}
<p>This integration requires you to provide some secrets. These will be sent to the server controlled by
the creator of {integration.name}, at: <code>{integration.webhook_url}</code></p>
<p>Note, the integration creator may change the server at any time.</p>
<div class="secret-container">
{#each integration.secrets as secret}
<div class="secret-input">
<Input col1 label="{secret.name}" placeholder="{secret.name}" bind:value={secretValues[secret.name]}/>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<div slot="footer" style="gap: 12px">
<Button danger icon="fas fa-trash-can" on:click={removeIntegration}>Remove from server</Button>
<Button icon="fas fa-floppy-disk" disabled={!allValuesFilled} on:click={saveSecrets}>Save Integration</Button>
</div>
</Card>
</div>
</div>
</div>
<script>
import {notifyError, notifySuccess, withLoadingScreen} from '../../js/util'
import axios from "axios";
import {API_URL} from "../../js/constants";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Card from "../../components/Card.svelte";
import Button from "../../components/Button.svelte";
import Input from "../../components/form/Input.svelte";
import {navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let integrationId = currentRoute.namedParams.integration;
let integration = {};
let secretValues = {};
let allValuesFilled = true;
$: secretValues, updateAllValuesFilled();
function updateAllValuesFilled() {
if (integration.secrets === undefined) {
return;
}
if (Object.keys(secretValues).length !== integration.secrets.length) {
allValuesFilled = false;
return;
}
for (let key in secretValues) {
if (secretValues[key] === '') {
allValuesFilled = false;
return;
}
}
allValuesFilled = true;
}
async function saveSecrets() {
let data = {
secrets: secretValues
};
const res = await axios.patch(`${API_URL}/api/${guildId}/integrations/${integrationId}`, data);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
notifySuccess('Integration settings saved successfully');
}
async function removeIntegration() {
const res = await axios.delete(`${API_URL}/api/${guildId}/integrations/${integrationId}`);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/integrations?removed=true`);
}
async function loadIntegration() {
let res = await axios.get(`${API_URL}/api/integrations/view/${integrationId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
integration = res.data;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await Promise.all([
loadIntegration()
]);
updateAllValuesFilled();
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: row;
justify-content: center;
width: 96%;
height: 100%;
margin-top: 30px;
padding-bottom: 5vh;
gap: 2%;
}
.col-left {
width: 60%;
}
.body-wrapper {
display: flex;
flex-direction: column;
row-gap: 1vh;
}
.secret-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2%;
}
.secret-input {
flex: 0 0 49%;
}
@media only screen and (max-width: 950px) {
.content {
flex-direction: column;
row-gap: 2vh;
}
.col-left {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,174 @@
<div class="parent">
<div class="content">
<div class="col-left">
<Card footer footerRight>
<span slot="title">About {integration.name}</span>
<div slot="body" class="body-wrapper">
<span class="description">{integration.description}</span>
<p style="padding-top: 5px">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:</p>
<input readonly value={integration.webhook_url} class="form-input"/>
{#if privacy_policy_url === null}
<p>The integration author has not provided a privacy policy.</p>
{:else}
<p>The integration author has provided a privacy policy, accessible at
<a href="{privacy_policy_url}" class="link-blue">{privacy_policy_url}</a>
</p>
{/if}
</div>
<div slot="footer">
{#if isActive}
<Button on:click={removeIntegration} danger>Remove from server</Button>
{:else}
<Button on:click={() => navigateTo(`/manage/${guildId}/integrations/activate/${integrationId}`)}>
Add to server
</Button>
{/if}
</div>
</Card>
</div>
<div class="col-right">
<Card footer={false} fill={false}>
<span slot="title">Placeholders</span>
<div slot="body">
<p>The following placeholders are available to user in welcome messages through the <i>{integration.name}</i>
integration:</p>
<div class="placeholders">
{#if integration.placeholders}
{#each integration.placeholders as placeholder}
<Badge>%{placeholder.name}%</Badge>
{/each}
{/if}
</div>
</div>
</Card>
</div>
</div>
</div>
<script>
import {notifyError, notifySuccess, withLoadingScreen} from '../../js/util'
import axios from "axios";
import {API_URL} from "../../js/constants";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Card from "../../components/Card.svelte";
import Button from "../../components/Button.svelte";
import Badge from "../../components/Badge.svelte";
import {Navigate, navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let integrationId = currentRoute.namedParams.integration;
let integration = {};
let isActive = false;
let privacy_policy_url = null;
async function removeIntegration() {
const res = await axios.delete(`${API_URL}/api/${guildId}/integrations/${integrationId}`);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/integrations?removed=true`);
}
async function loadIntegration() {
let res = await axios.get(`${API_URL}/api/integrations/view/${integrationId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
integration = res.data;
if (integration.privacy_policy_url !== null) {
let tmp = new URL(integration.privacy_policy_url);
if (tmp.protocol === "http:" || tmp.protocol === "https:") {
privacy_policy_url = tmp;
}
}
}
async function loadIsActive() {
let res = await axios.get(`${API_URL}/api/${guildId}/integrations/${integrationId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
isActive = res.data.active;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await Promise.all([
loadIntegration(),
loadIsActive()
]);
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 96%;
height: 100%;
margin-top: 30px;
padding-bottom: 5vh;
gap: 2%;
}
.col-left {
width: 60%;
}
.col-right {
width: 40%;
}
.placeholders {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.description {
border-bottom: 1px solid #777;
padding-bottom: 5px;
}
.body-wrapper {
display: flex;
flex-direction: column;
}
@media only screen and (max-width: 950px) {
.content {
flex-direction: column;
row-gap: 2vh;
}
.col-left, .col-right {
width: 100%;
}
}
</style>

9
go.mod
View File

@ -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
)

10
go.sum
View File

@ -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=

19
utils/imageproxy.go Normal file
View File

@ -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))
}

12
utils/netutils.go Normal file
View File

@ -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()
}

View File

@ -11,3 +11,7 @@ func ValueOrZero[T any](v *T) T {
return *v
}
}
func Slice[T any](v ...T) []T {
return v
}