diff --git a/app/http/endpoints/api/export/import.go b/app/http/endpoints/api/export/import.go new file mode 100644 index 0000000..ee3a192 --- /dev/null +++ b/app/http/endpoints/api/export/import.go @@ -0,0 +1,762 @@ +package api + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "os" + + "github.com/TicketsBot/GoPanel/app/http/endpoints/api/export/validator" + "github.com/TicketsBot/GoPanel/botcontext" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/rpc" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/common/premium" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +func ImportHandler(ctx *gin.Context) { + // Parse request body from multipart form + + var transcriptOutput *validator.GuildTranscriptsOutput + var data *validator.GuildData + + dataFile, _, err := ctx.Request.FormFile("data_file") + dataFileExists := err == nil + + transcriptsFile, _, err := ctx.Request.FormFile("transcripts_file") + transcriptFileExists := err == nil + + // Decrypt file + publicKeyBlock, _ := pem.Decode([]byte(os.Getenv("V1_PUBLIC_KEY"))) + if publicKeyBlock == nil { + ctx.JSON(400, utils.ErrorStr("Invalid public key")) + return + } + + parsedKey, err := x509.ParsePKIXPublicKey(publicKeyBlock.Bytes) + if err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + decryptedPublicKey, ok := parsedKey.(ed25519.PublicKey) + if !ok { + ctx.JSON(400, utils.ErrorStr("Invalid public key")) + return + } + + v := validator.NewValidator( + decryptedPublicKey, + validator.WithMaxUncompressedSize(250*1024*1024), + validator.WithMaxIndividualFileSize(1*1024*1024), + ) + + if dataFileExists { + defer dataFile.Close() + + dataBuffer := bytes.NewBuffer(nil) + if _, err := io.Copy(dataBuffer, dataFile); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + dataReader := bytes.NewReader(dataBuffer.Bytes()) + data, err = v.ValidateGuildData(dataReader, dataReader.Size()) + if err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + } + + if transcriptFileExists { + defer transcriptsFile.Close() + + transcriptsBuffer := bytes.NewBuffer(nil) + if _, err := io.Copy(transcriptsBuffer, transcriptsFile); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + transcriptReader := bytes.NewReader(transcriptsBuffer.Bytes()) + transcriptOutput, err = v.ValidateGuildTranscripts(transcriptReader, transcriptReader.Size()) + if err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + } + + guildId, selfId := ctx.Keys["guildid"].(uint64), ctx.Keys["userid"].(uint64) + + botCtx, err := botcontext.ContextForGuild(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + guild, err := botCtx.GetGuild(context.Background(), guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if guild.OwnerId != selfId { + ctx.JSON(403, utils.ErrorStr("Only the server owner import server data")) + return + } + + if dataFileExists && data.GuildId != guildId || transcriptFileExists && transcriptOutput.GuildId != guildId { + ctx.JSON(400, utils.ErrorStr("Invalid guild Id")) + return + } + + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botCtx.Token, botCtx.RateLimiter) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Get ticket maps + mapping, err := dbclient.Client2.ImportMappingTable.GetMapping(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + var ( + ticketIdMap = mapping["ticket"] + formIdMap = mapping["form"] + formInputIdMap = mapping["form_input"] + ) + + if ticketIdMap == nil { + ticketIdMap = make(map[int]int) + } + + if formIdMap == nil { + formIdMap = make(map[int]int) + } + + if formInputIdMap == nil { + formInputIdMap = make(map[int]int) + } + + if dataFileExists { + + group, _ := errgroup.WithContext(ctx) + + // Import active language + group.Go(func() (err error) { + lang := "en" + if data.ActiveLanguage != nil { + lang = *data.ActiveLanguage + } + _ = dbclient.Client.ActiveLanguage.Set(ctx, guildId, lang) + + return + }) + + // Import archive channel + group.Go(func() (err error) { + if data.ArchiveChannel != nil { + err = dbclient.Client.ArchiveChannel.Set(ctx, guildId, data.ArchiveChannel) + } + + return + }) + + // import AutocloseSettings + group.Go(func() (err error) { + if data.AutocloseSettings != nil { + if premiumTier < premium.Premium { + data.AutocloseSettings.Enabled = false + } + err = dbclient.Client.AutoClose.Set(ctx, guildId, *data.AutocloseSettings) + } + + return + }) + + // Import blacklisted users + group.Go(func() (err error) { + for _, user := range data.GuildBlacklistedUsers { + err = dbclient.Client.Blacklist.Add(ctx, guildId, user) + if err != nil { + return + } + } + + return + }) + + // Import channel category + group.Go(func() (err error) { + if data.ChannelCategory != nil { + err = dbclient.Client.ChannelCategory.Set(ctx, guildId, *data.ChannelCategory) + } + + return + }) + + // Import claim settings + group.Go(func() (err error) { + if data.ClaimSettings != nil { + err = dbclient.Client.ClaimSettings.Set(ctx, guildId, *data.ClaimSettings) + } + + return + }) + + // Import close confirmation enabled + group.Go(func() (err error) { + err = dbclient.Client.CloseConfirmation.Set(ctx, guildId, data.CloseConfirmationEnabled) + + return + }) + + // Import custom colours + group.Go(func() (err error) { + if premiumTier < premium.Premium { + return + } + + for k, v := range data.CustomColors { + err = dbclient.Client.CustomColours.Set(ctx, guildId, k, v) + if err != nil { + return + } + } + + return + }) + + // Import feedback enabled + group.Go(func() (err error) { + err = dbclient.Client.FeedbackEnabled.Set(ctx, guildId, data.FeedbackEnabled) + + return + }) + + // Import is globally blacklisted + group.Go(func() (err error) { + if data.GuildIsGloballyBlacklisted { + reason := "Blacklisted on v1" + err = dbclient.Client.ServerBlacklist.Add(ctx, guildId, &reason) + } + return + }) + + // Import Guild Metadata + group.Go(func() (err error) { + err = dbclient.Client.GuildMetadata.Set(ctx, guildId, data.GuildMetadata) + + return + }) + + // Import Naming Scheme + group.Go(func() (err error) { + if data.NamingScheme != nil { + err = dbclient.Client.NamingScheme.Set(ctx, guildId, *data.NamingScheme) + } + + return + }) + + // Import On Call Users + group.Go(func() (err error) { + for _, user := range data.OnCallUsers { + if isOnCall, oncallerr := dbclient.Client.OnCall.IsOnCall(ctx, guildId, user); oncallerr != nil { + return oncallerr + } else if !isOnCall { + _, err = dbclient.Client.OnCall.Toggle(ctx, guildId, user) + if err != nil { + return + } + } + } + + return + }) + + // Import User Permissions + group.Go(func() (err error) { + for _, perm := range data.UserPermissions { + if perm.IsSupport { + err = dbclient.Client.Permissions.AddSupport(ctx, guildId, perm.Snowflake) + } + + if perm.IsAdmin { + err = dbclient.Client.Permissions.AddAdmin(ctx, guildId, perm.Snowflake) + } + + if err != nil { + return + } + } + + return + }) + + // Import Guild Blacklisted Roles + group.Go(func() (err error) { + for _, role := range data.GuildBlacklistedRoles { + err = dbclient.Client.RoleBlacklist.Add(ctx, guildId, role) + if err != nil { + return + } + } + + return + }) + + // Import Role Permissions + group.Go(func() (err error) { + for _, perm := range data.RolePermissions { + if perm.IsSupport { + err = dbclient.Client.RolePermissions.AddSupport(ctx, guildId, perm.Snowflake) + } + + if perm.IsAdmin { + err = dbclient.Client.RolePermissions.AddAdmin(ctx, guildId, perm.Snowflake) + } + + if err != nil { + return + } + } + + return + }) + + // Import Tags + group.Go(func() (err error) { + for _, tag := range data.Tags { + err = dbclient.Client.Tag.Set(ctx, tag) + if err != nil { + return + } + } + + return + }) + + // Import Ticket Limit + group.Go(func() (err error) { + if data.TicketLimit != nil { + err = dbclient.Client.TicketLimit.Set(ctx, guildId, uint8(*data.TicketLimit)) + } + + return + }) + + // Import Ticket Permissions + group.Go(func() (err error) { + err = dbclient.Client.TicketPermissions.Set(ctx, guildId, data.TicketPermissions) + + return + }) + + // Import Users Can Close + group.Go(func() (err error) { + err = dbclient.Client.UsersCanClose.Set(ctx, guildId, data.UsersCanClose) + + return + }) + + // Import Welcome Message + group.Go(func() (err error) { + if data.WelcomeMessage != nil { + err = dbclient.Client.WelcomeMessages.Set(ctx, guildId, *data.WelcomeMessage) + } + + return + }) + + if err := group.Wait(); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + supportTeamIdMap := make(map[int]int) + + // Import Support Teams + for _, team := range data.SupportTeams { + teamId, err := dbclient.Client.SupportTeam.Create(ctx, guildId, team.Name) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + supportTeamIdMap[team.Id] = teamId + } + + // Import Support Team Users + for teamId, users := range data.SupportTeamUsers { + for _, user := range users { + if err := dbclient.Client.SupportTeamMembers.Add(ctx, supportTeamIdMap[teamId], user); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + + // Import Support Team Roles + for teamId, roles := range data.SupportTeamRoles { + for _, role := range roles { + if err := dbclient.Client.SupportTeamRoles.Add(ctx, supportTeamIdMap[teamId], role); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + + // Import forms + for _, form := range data.Forms { + if _, ok := formIdMap[form.Id]; !ok { + formId, err := dbclient.Client.Forms.Create(ctx, guildId, form.Title, form.CustomId) + if err != nil { + return + } + + formIdMap[form.Id] = formId + } + } + + // Import form inputs + for _, input := range data.FormInputs { + if _, ok := formInputIdMap[input.Id]; !ok { + newInputId, err := dbclient.Client.FormInput.Create(ctx, formIdMap[input.FormId], input.CustomId, input.Style, input.Label, input.Placeholder, input.Required, input.MinLength, input.MaxLength) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + formInputIdMap[input.Id] = newInputId + } + } + + embedMap := make(map[int]int) + + // Import embeds + for _, embed := range data.Embeds { + var embedFields []database.EmbedField + + for _, field := range data.EmbedFields { + if field.EmbedId == embed.Id { + embedFields = append(embedFields, field) + } + } + + embed.GuildId = guildId + + embedId, err := dbclient.Client.Embeds.CreateWithFields(ctx, &embed, embedFields) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + fmt.Println("Embed ID", embed.Id, "New ID", embedId) + + embedMap[embed.Id] = embedId + } + + // Panel id map + existingPanels, err := dbclient.Client.Panel.GetByGuild(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + panelTx, err := dbclient.Client.Panel.Begin(ctx) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + panelCount := len(existingPanels) + + panelIdMap := make(map[int]int) + + for _, panel := range data.Panels { + if premiumTier < premium.Premium && panelCount > 2 { + panel.ForceDisabled = true + panel.Disabled = true + } + + if panel.FormId != nil { + newFormId := formIdMap[*panel.FormId] + panel.FormId = &newFormId + } + + if panel.ExitSurveyFormId != nil { + newFormId := formIdMap[*panel.ExitSurveyFormId] + panel.ExitSurveyFormId = &newFormId + } + + if panel.WelcomeMessageEmbed != nil { + fmt.Println(*panel.WelcomeMessageEmbed) + newEmbedId := embedMap[*panel.WelcomeMessageEmbed] + fmt.Println(newEmbedId) + panel.WelcomeMessageEmbed = &newEmbedId + } + + panelId, err := dbclient.Client.Panel.CreateWithTx(ctx, panelTx, panel) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + panelIdMap[panel.PanelId] = panelId + + panelCount++ + } + + // Import Panel Access Control Rules + for panelId, rules := range data.PanelAccessControlRules { + if err := dbclient.Client.PanelAccessControlRules.ReplaceWithTx(ctx, panelTx, panelIdMap[panelId], rules); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Import Panel Mention User + for panelId, shouldMention := range data.PanelMentionUser { + if err := dbclient.Client.PanelUserMention.SetWithTx(ctx, panelTx, panelIdMap[panelId], shouldMention); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Import Panel Role Mentions + for panelId, roles := range data.PanelRoleMentions { + if err := dbclient.Client.PanelRoleMentions.ReplaceWithTx(ctx, panelTx, panelIdMap[panelId], roles); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Import Panel Teams + for panelId, teams := range data.PanelTeams { + teamsToAdd := make([]int, len(teams)) + for _, team := range teams { + teamsToAdd = append(teamsToAdd, supportTeamIdMap[team]) + } + + if err := dbclient.Client.PanelTeams.ReplaceWithTx(ctx, panelTx, panelIdMap[panelId], teamsToAdd); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + if err := panelTx.Commit(ctx); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Import Multi panels + multiPanelIdMap := make(map[int]int) + + for _, multiPanel := range data.MultiPanels { + multiPanelId, err := dbclient.Client.MultiPanels.Create(ctx, multiPanel) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + multiPanelIdMap[multiPanel.Id] = multiPanelId + } + + // Import Multi Panel Targets + for multiPanelId, panelIds := range data.MultiPanelTargets { + for _, panelId := range panelIds { + if err := dbclient.Client.MultiPanelTargets.Insert(ctx, multiPanelIdMap[multiPanelId], panelIdMap[panelId]); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + + newContextMenuPanel := panelIdMap[*data.Settings.ContextMenuPanel] + data.Settings.ContextMenuPanel = &newContextMenuPanel + + if err := dbclient.Client.Settings.Set(ctx, guildId, data.Settings); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Import tickets + for _, ticket := range data.Tickets { + if _, ok := ticketIdMap[ticket.Id]; !ok { + newPanelId := panelIdMap[*ticket.PanelId] + newTicketId, err := dbclient.Client.Tickets.Create(ctx, guildId, ticket.UserId, ticket.IsThread, &newPanelId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ticketIdMap[ticket.Id] = newTicketId + + if ticket.Open { + if err := dbclient.Client.Tickets.SetOpen(ctx, guildId, newTicketId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } else { + if err := dbclient.Client.Tickets.Close(ctx, newTicketId, guildId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + if err := dbclient.Client.Tickets.SetChannelId(ctx, guildId, newTicketId, *ticket.ChannelId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + if err := dbclient.Client.Tickets.SetHasTranscript(ctx, guildId, newTicketId, ticket.HasTranscript); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + if ticket.NotesThreadId != nil { + if err := dbclient.Client.Tickets.SetNotesThreadId(ctx, guildId, newTicketId, *ticket.NotesThreadId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + if err := dbclient.Client.Tickets.SetStatus(ctx, guildId, newTicketId, ticket.Status); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + } + + // Upload transcripts + if transcriptFileExists { + for ticketId, transcript := range transcriptOutput.Transcripts { + if err := utils.ArchiverClient.ImportTranscript(ctx, guildId, ticketIdMap[ticketId], transcript); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + + if dataFileExists { + + ticketsExtrasGroup, _ := errgroup.WithContext(ctx) + + ticketsExtrasGroup.Go(func() (err error) { + // Import ticket additional members + for ticketId, members := range data.TicketAdditionalMembers { + for _, member := range members { + err = dbclient.Client.TicketMembers.Add(ctx, guildId, ticketIdMap[ticketId], member) + return + } + } + return + }) + + // Import ticket last messages + ticketsExtrasGroup.Go(func() (err error) { + for _, msg := range data.TicketLastMessages { + err = dbclient.Client.TicketLastMessage.Set(ctx, guildId, ticketIdMap[msg.TicketId], *msg.Data.LastMessageId, *msg.Data.UserId, *msg.Data.UserIsStaff) + } + return + }) + + // Import ticket claims + ticketsExtrasGroup.Go(func() (err error) { + for _, claim := range data.TicketClaims { + err = dbclient.Client.TicketClaims.Set(ctx, guildId, ticketIdMap[claim.TicketId], claim.Data) + } + return + }) + + // Import ticket ratings + ticketsExtrasGroup.Go(func() (err error) { + for _, rating := range data.ServiceRatings { + err = dbclient.Client.ServiceRatings.Set(ctx, guildId, ticketIdMap[rating.TicketId], uint8(rating.Data)) + } + return + }) + + // Import participants + ticketsExtrasGroup.Go(func() (err error) { + for ticketId, participants := range data.Participants { + err = dbclient.Client.Participants.SetBulk(ctx, guildId, ticketIdMap[ticketId], participants) + } + return + }) + + // Import First Response Times + ticketsExtrasGroup.Go(func() (err error) { + for _, frt := range data.FirstResponseTimes { + err = dbclient.Client.FirstResponseTime.Set(ctx, guildId, frt.UserId, ticketIdMap[frt.TicketId], frt.ResponseTime) + } + return + }) + + ticketsExtrasGroup.Go(func() (err error) { + for _, response := range data.ExitSurveyResponses { + + resps := map[int]string{ + *response.Data.QuestionId: *response.Data.Response, + } + + err = dbclient.Client.ExitSurveyResponses.AddResponses(ctx, guildId, ticketIdMap[response.TicketId], formIdMap[*response.Data.FormId], resps) + } + return + }) + + // Import Close Reasons + ticketsExtrasGroup.Go(func() (err error) { + for _, reason := range data.CloseReasons { + err = dbclient.Client.CloseReason.Set(ctx, guildId, ticketIdMap[reason.TicketId], reason.Data) + } + return + }) + + // Import Autoclose Excluded Tickets + ticketsExtrasGroup.Go(func() (err error) { + for _, ticketId := range data.AutocloseExcluded { + err = dbclient.Client.AutoCloseExclude.Exclude(ctx, guildId, ticketIdMap[ticketId]) + } + return + }) + + // Import Archive Messages + ticketsExtrasGroup.Go(func() (err error) { + for _, message := range data.ArchiveMessages { + err = dbclient.Client.ArchiveMessages.Set(ctx, guildId, ticketIdMap[message.TicketId], message.Data.ChannelId, message.Data.MessageId) + } + return + }) + + if err := ticketsExtrasGroup.Wait(); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + } + + // Imported successfully, update the import mapping + newMapping := make(map[string]map[int]int) + newMapping["ticket"] = ticketIdMap + newMapping["form"] = formIdMap + newMapping["form_input"] = formInputIdMap + + for area, m := range newMapping { + for sourceId, targetId := range m { + if err := dbclient.Client2.ImportMappingTable.Set(ctx, guildId, area, sourceId, targetId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + } + + ctx.JSON(200, utils.SuccessResponse) +} diff --git a/app/http/endpoints/api/export/model.go b/app/http/endpoints/api/export/model.go new file mode 100644 index 0000000..778f64e --- /dev/null +++ b/app/http/endpoints/api/export/model.go @@ -0,0 +1 @@ +package api diff --git a/app/http/endpoints/api/export/validator/error.go b/app/http/endpoints/api/export/validator/error.go new file mode 100644 index 0000000..6203ed7 --- /dev/null +++ b/app/http/endpoints/api/export/validator/error.go @@ -0,0 +1,8 @@ +package validator + +import "errors" + +var ( + ErrValidationFailed = errors.New("validation failed") + ErrMaximumSizeExceeded = errors.New("maximum size exceeded") +) diff --git a/app/http/endpoints/api/export/validator/guild_data.go b/app/http/endpoints/api/export/validator/guild_data.go new file mode 100644 index 0000000..917f738 --- /dev/null +++ b/app/http/endpoints/api/export/validator/guild_data.go @@ -0,0 +1,37 @@ +package validator + +import ( + "archive/zip" + "encoding/json" + "io" +) + +func (v *Validator) ValidateGuildData(input io.ReaderAt, size int64) (*GuildData, error) { + reader, err := zip.NewReader(input, size) + if err != nil { + return nil, err + } + + f, err := reader.Open("data.json") + if err != nil { + return nil, err + } + + defer f.Close() + + data, err := io.ReadAll(v.newLimitReader(f)) + if err != nil { + return nil, err + } + + if _, err := v.validateSignature(reader, "data.json", data); err != nil { + return nil, err + } + + var guildData GuildData + if err := json.Unmarshal(data, &guildData); err != nil { + return nil, err + } + + return &guildData, nil +} diff --git a/app/http/endpoints/api/export/validator/guild_transcripts.go b/app/http/endpoints/api/export/validator/guild_transcripts.go new file mode 100644 index 0000000..a4dab57 --- /dev/null +++ b/app/http/endpoints/api/export/validator/guild_transcripts.go @@ -0,0 +1,97 @@ +package validator + +import ( + "archive/zip" + "io" + "regexp" + "strconv" +) + +type GuildTranscriptsOutput struct { + GuildId uint64 + // Ticket ID -> Transcript + Transcripts map[int][]byte +} + +var transcriptFileRegex = regexp.MustCompile(`^transcripts/(\d+)\.json$`) + +func (v *Validator) ValidateGuildTranscripts(input io.ReaderAt, size int64) (*GuildTranscriptsOutput, error) { + reader, err := zip.NewReader(input, size) + if err != nil { + return nil, err + } + + guildId, n, err := v.readGuildId(reader) + if err != nil { + return nil, err + } + + transcripts := make(map[int][]byte) + for _, f := range reader.File { + matches := transcriptFileRegex.FindStringSubmatch(f.Name) + if len(matches) != 2 { + continue + } + + ticketId, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + + file, err := f.Open() + if err != nil { + return nil, err + } + + b, err := io.ReadAll(v.newLimitReader(file)) + if err != nil { + return nil, err + } + + n += int64(len(b)) + if n > v.maxUncompressedSize { + return nil, ErrMaximumSizeExceeded + } + + sigSize, err := v.validateTranscriptSignature(reader, f.Name, guildId, ticketId, b) + if err != nil { + return nil, err + } + + n += sigSize + if n > v.maxUncompressedSize { + return nil, ErrMaximumSizeExceeded + } + + transcripts[ticketId] = b + } + + return &GuildTranscriptsOutput{ + GuildId: guildId, + Transcripts: transcripts, + }, nil +} + +func (v *Validator) readGuildId(reader *zip.Reader) (uint64, int64, error) { + f, err := reader.Open("guild_id.txt") + if err != nil { + return 0, 0, err + } + + b, err := io.ReadAll(v.newLimitReader(f)) + if err != nil { + return 0, 0, err + } + + guildId, err := strconv.ParseUint(string(b), 10, 64) + if err != nil { + return 0, 0, err + } + + sigSize, err := v.validateSignature(reader, "guild_id.txt", b) + if err != nil { + return 0, 0, err + } + + return guildId, int64(len(b)) + sigSize, nil +} diff --git a/app/http/endpoints/api/export/validator/model.go b/app/http/endpoints/api/export/validator/model.go new file mode 100644 index 0000000..e0c7f2a --- /dev/null +++ b/app/http/endpoints/api/export/validator/model.go @@ -0,0 +1,83 @@ +package validator + +import ( + "time" + + "github.com/TicketsBot/database" +) + +type TicketUnion[T any] struct { + TicketId int `json:"ticket_id"` + Data T `json:"data"` +} + +type GuildData struct { + GuildId uint64 `json:"guild_id,string"` + ActiveLanguage *string `json:"active_language"` + ArchiveChannel *uint64 `json:"archive_channel,string"` + ArchiveMessages []TicketUnion[database.ArchiveMessage] `json:"archive_messages"` + AutocloseSettings *database.AutoCloseSettings `json:"autoclose_settings"` + AutocloseExcluded []int `json:"autoclose_excluded"` // ticket IDs + GuildBlacklistedUsers []uint64 `json:"guild_blacklisted_users"` + ChannelCategory *uint64 `json:"channel_category,string"` + ClaimSettings *database.ClaimSettings `json:"claim_settings"` + CloseConfirmationEnabled bool `json:"close_confirmation_enabled"` + CloseReasons []TicketUnion[database.CloseMetadata] `json:"close_reasons"` + CustomColors map[int16]int `json:"custom_colors"` + EmbedFields []database.EmbedField `json:"embed_fields"` + Embeds []database.CustomEmbed `json:"embeds"` + ExitSurveyResponses []TicketUnion[ExitSurveyResponse] `json:"exit_survey_responses"` + FeedbackEnabled bool `json:"feedback_enabled"` + FirstResponseTimes []FirstResponseTime `json:"first_response_times"` + FormInputs []database.FormInput `json:"form_inputs"` + Forms []database.Form `json:"forms"` + GuildIsGloballyBlacklisted bool `json:"guild_is_globally_blacklisted"` + GuildMetadata database.GuildMetadata `json:"guild_metadata"` + MultiPanels []database.MultiPanel `json:"multi_panels"` + MultiPanelTargets map[int][]int `json:"multi_panel_targets"` // multi_panel_id -> [panel_ids] + NamingScheme *database.NamingScheme `json:"naming_scheme"` + OnCallUsers []uint64 `json:"on_call_users"` + PanelAccessControlRules map[int][]database.PanelAccessControlRule `json:"panel_access_control_rules"` // panel_id -> rules + PanelMentionUser map[int]bool `json:"panel_mention_user"` + PanelRoleMentions map[int][]uint64 `json:"panel_role_mentions"` + Panels []database.Panel `json:"panels"` + PanelTeams map[int][]int `json:"panel_teams"` // panel_id -> [team_ids] + Participants map[int][]uint64 `json:"participants"` // ticket_id -> [user_ids] + UserPermissions []Permission `json:"user_permissions"` + GuildBlacklistedRoles []uint64 `json:"guild_blacklisted_roles"` + RolePermissions []Permission `json:"role_permissions"` + ServiceRatings []TicketUnion[int16] `json:"service_ratings"` + Settings database.Settings `json:"settings"` + SupportTeamUsers map[int][]uint64 `json:"support_team_users"` // team_id -> [user_ids] + SupportTeamRoles map[int][]uint64 `json:"support_team_roles"` // team_id -> [role_ids] + SupportTeams []database.SupportTeam `json:"support_teams"` + Tags []database.Tag `json:"tags"` + TicketClaims []TicketUnion[uint64] `json:"ticket_claims"` + TicketLastMessages []TicketUnion[database.TicketLastMessage] `json:"ticket_last_messages"` + TicketLimit *int `json:"ticket_limit"` + TicketAdditionalMembers map[int][]uint64 `json:"ticket_additional_members"` // ticket_id -> [user_ids] + TicketPermissions database.TicketPermissions `json:"ticket_permissions"` + Tickets []database.Ticket `json:"tickets"` + UsersCanClose bool `json:"users_can_close"` + WelcomeMessage *string `json:"welcome_message"` +} + +// Shims + +type FirstResponseTime struct { + TicketId int `json:"ticket_id"` + UserId uint64 `json:"user_id,string"` + ResponseTime time.Duration `json:"response_time"` +} + +type Permission struct { + Snowflake uint64 `json:"snowflake,string"` + IsSupport bool `json:"is_support"` + IsAdmin bool `json:"is_admin"` +} + +type ExitSurveyResponse struct { + FormId *int `json:"form_id"` + QuestionId *int `json:"question_id"` + Response *string `json:"response"` +} diff --git a/app/http/endpoints/api/export/validator/signatures.go b/app/http/endpoints/api/export/validator/signatures.go new file mode 100644 index 0000000..16fed66 --- /dev/null +++ b/app/http/endpoints/api/export/validator/signatures.go @@ -0,0 +1,52 @@ +package validator + +import ( + "archive/zip" + "crypto/ed25519" + "encoding/base64" + "io" + "strconv" +) + +func (v *Validator) validateSignature(zipReader *zip.Reader, fileName string, data []byte) (int64, error) { + f, err := zipReader.Open(fileName + ".sig") + if err != nil { + return 0, err + } + + signature, err := io.ReadAll(v.newLimitReader(f)) + if err != nil { + return 0, err + } + + decoded, err := base64.RawURLEncoding.DecodeString(string(signature)) + if err != nil { + return 0, err + } + + if !ed25519.Verify(v.publicKey, data, decoded) { + return 0, ErrValidationFailed + } + + return int64(len(signature)), nil +} + +func (v *Validator) validateTranscriptSignature( + zipReader *zip.Reader, + fileName string, + guildId uint64, + ticketId int, + data []byte, +) (int64, error) { + guildIdStr := strconv.FormatUint(guildId, 10) + ticketIdStr := strconv.Itoa(ticketId) + + sigData := make([]byte, 0, len(guildIdStr)+len(ticketIdStr)+len(data)+2) + sigData = append(sigData, guildIdStr...) + sigData = append(sigData, '|') + sigData = append(sigData, ticketIdStr...) + sigData = append(sigData, '|') + sigData = append(sigData, data...) + + return v.validateSignature(zipReader, fileName, sigData) +} diff --git a/app/http/endpoints/api/export/validator/validator.go b/app/http/endpoints/api/export/validator/validator.go new file mode 100644 index 0000000..183f08a --- /dev/null +++ b/app/http/endpoints/api/export/validator/validator.go @@ -0,0 +1,45 @@ +package validator + +import ( + "crypto/ed25519" + "io" +) + +type Validator struct { + publicKey ed25519.PublicKey + + maxUncompressedSize int64 + maxIndividualFileSize int64 +} + +type Option func(*Validator) + +func NewValidator(publicKey ed25519.PublicKey, options ...Option) *Validator { + v := &Validator{ + publicKey: publicKey, + maxUncompressedSize: 250 * 1024 * 1024, + maxIndividualFileSize: 1 * 1024 * 1024, + } + + for _, option := range options { + option(v) + } + + return v +} + +func WithMaxUncompressedSize(size int64) Option { + return func(v *Validator) { + v.maxUncompressedSize = size + } +} + +func WithMaxIndividualFileSize(size int64) Option { + return func(v *Validator) { + v.maxIndividualFileSize = size + } +} + +func (v *Validator) newLimitReader(r io.Reader) io.Reader { + return io.LimitReader(r, v.maxIndividualFileSize) +} diff --git a/app/http/endpoints/api/transcripts/get.go b/app/http/endpoints/api/transcripts/get.go index 36b29a8..954541f 100644 --- a/app/http/endpoints/api/transcripts/get.go +++ b/app/http/endpoints/api/transcripts/get.go @@ -3,11 +3,12 @@ package api import ( "context" "errors" + "strconv" + + "github.com/TicketsBot-cloud/archiverclient" dbclient "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/utils" - "github.com/TicketsBot/archiverclient" "github.com/gin-gonic/gin" - "strconv" ) func GetTranscriptHandler(ctx *gin.Context) { diff --git a/app/http/endpoints/api/transcripts/render.go b/app/http/endpoints/api/transcripts/render.go index bda2b97..fe0ab7a 100644 --- a/app/http/endpoints/api/transcripts/render.go +++ b/app/http/endpoints/api/transcripts/render.go @@ -2,12 +2,13 @@ package api import ( "errors" + "strconv" + + "github.com/TicketsBot-cloud/archiverclient" "github.com/TicketsBot/GoPanel/chatreplica" dbclient "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/utils" - "github.com/TicketsBot/archiverclient" "github.com/gin-gonic/gin" - "strconv" ) func GetTranscriptRenderHandler(ctx *gin.Context) { diff --git a/app/http/server.go b/app/http/server.go index a5b501e..43cdf5b 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -1,9 +1,12 @@ package http import ( + "time" + "github.com/TicketsBot/GoPanel/app/http/endpoints/api" "github.com/TicketsBot/GoPanel/app/http/endpoints/api/admin/botstaff" api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist" + api_import "github.com/TicketsBot/GoPanel/app/http/endpoints/api/export" 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" @@ -24,7 +27,6 @@ import ( "github.com/gin-gonic/gin" "github.com/penglongli/gin-metrics/ginmetrics" "go.uber.org/zap" - "time" ) func StartServer(logger *zap.Logger, sm *livechat.SocketManager) { @@ -120,6 +122,8 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) { guildAuthApiSupport.GET("/settings", api_settings.GetSettingsHandler) guildAuthApiAdmin.POST("/settings", api_settings.UpdateSettingsHandler) + guildAuthApiAdmin.POST("/import", api_import.ImportHandler) + guildAuthApiSupport.GET("/blacklist", api_blacklist.GetBlacklistHandler) guildAuthApiSupport.POST("/blacklist", api_blacklist.AddBlacklistHandler) guildAuthApiSupport.DELETE("/blacklist/user/:user", api_blacklist.RemoveUserBlacklistHandler) diff --git a/cmd/api/main.go b/cmd/api/main.go index 7fb49ca..f09d944 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,6 +2,10 @@ package main import ( "fmt" + "net/http" + "net/http/pprof" + + "github.com/TicketsBot-cloud/archiverclient" app "github.com/TicketsBot/GoPanel/app/http" "github.com/TicketsBot/GoPanel/app/http/endpoints/api/ticket/livechat" "github.com/TicketsBot/GoPanel/config" @@ -10,7 +14,6 @@ import ( "github.com/TicketsBot/GoPanel/rpc" "github.com/TicketsBot/GoPanel/rpc/cache" "github.com/TicketsBot/GoPanel/utils" - "github.com/TicketsBot/archiverclient" "github.com/TicketsBot/common/chatrelay" "github.com/TicketsBot/common/model" "github.com/TicketsBot/common/observability" @@ -21,8 +24,8 @@ import ( "github.com/rxdn/gdl/rest/request" "go.uber.org/zap" "go.uber.org/zap/zapcore" - "net/http" - "net/http/pprof" + + _ "github.com/joho/godotenv/autoload" ) func main() { diff --git a/database/database.go b/database/database.go index 3e478f1..b9c1e53 100644 --- a/database/database.go +++ b/database/database.go @@ -2,6 +2,8 @@ package database import ( "context" + + database2 "github.com/TicketsBot-cloud/database" "github.com/TicketsBot/GoPanel/config" "github.com/TicketsBot/database" "github.com/jackc/pgconn" @@ -13,6 +15,7 @@ import ( ) var Client *database.Database +var Client2 *database2.Database func ConnectToDatabase() { config, err := pgxpool.ParseConfig(config.Conf.Database.Uri) @@ -37,4 +40,5 @@ func ConnectToDatabase() { } Client = database.NewDatabase(pool) + Client2 = database2.NewDatabase(pool) } diff --git a/frontend/src/components/manage/ImportModal.svelte b/frontend/src/components/manage/ImportModal.svelte new file mode 100644 index 0000000..bf84b16 --- /dev/null +++ b/frontend/src/components/manage/ImportModal.svelte @@ -0,0 +1,150 @@ +