1// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2// See LICENSE.txt for license information. 3 4package app 5 6import ( 7 "archive/zip" 8 "context" 9 "encoding/json" 10 "io" 11 "net/http" 12 "os" 13 "path/filepath" 14 "strings" 15 16 "github.com/pkg/errors" 17 18 "github.com/mattermost/mattermost-server/v6/model" 19 "github.com/mattermost/mattermost-server/v6/shared/mlog" 20 "github.com/mattermost/mattermost-server/v6/store" 21) 22 23type BulkExportOpts struct { 24 IncludeAttachments bool 25 CreateArchive bool 26} 27 28// ExportDataDir is the name of the directory were to store additional data 29// included with the export (e.g. file attachments). 30const ExportDataDir = "data" 31 32// We use this map to identify the exportable preferences. 33// Here we link the preference category and name, to the name of the relevant field in the import struct. 34var exportablePreferences = map[ComparablePreference]string{{ 35 Category: model.PreferenceCategoryTheme, 36 Name: "", 37}: "Theme", { 38 Category: model.PreferenceCategoryAdvancedSettings, 39 Name: "feature_enabled_markdown_preview", 40}: "UseMarkdownPreview", { 41 Category: model.PreferenceCategoryAdvancedSettings, 42 Name: "formatting", 43}: "UseFormatting", { 44 Category: model.PreferenceCategorySidebarSettings, 45 Name: "show_unread_section", 46}: "ShowUnreadSection", { 47 Category: model.PreferenceCategoryDisplaySettings, 48 Name: model.PreferenceNameUseMilitaryTime, 49}: "UseMilitaryTime", { 50 Category: model.PreferenceCategoryDisplaySettings, 51 Name: model.PreferenceNameCollapseSetting, 52}: "CollapsePreviews", { 53 Category: model.PreferenceCategoryDisplaySettings, 54 Name: model.PreferenceNameMessageDisplay, 55}: "MessageDisplay", { 56 Category: model.PreferenceCategoryDisplaySettings, 57 Name: "channel_display_mode", 58}: "ChannelDisplayMode", { 59 Category: model.PreferenceCategoryTutorialSteps, 60 Name: "", 61}: "TutorialStep", { 62 Category: model.PreferenceCategoryNotifications, 63 Name: model.PreferenceNameEmailInterval, 64}: "EmailInterval", 65} 66 67func (a *App) BulkExport(writer io.Writer, outPath string, opts BulkExportOpts) *model.AppError { 68 var zipWr *zip.Writer 69 if opts.CreateArchive { 70 var err error 71 zipWr = zip.NewWriter(writer) 72 defer zipWr.Close() 73 writer, err = zipWr.Create("import.jsonl") 74 if err != nil { 75 return model.NewAppError("BulkExport", "app.export.zip_create.error", 76 nil, "err="+err.Error(), http.StatusInternalServerError) 77 } 78 } 79 80 mlog.Info("Bulk export: exporting version") 81 if err := a.exportVersion(writer); err != nil { 82 return err 83 } 84 85 mlog.Info("Bulk export: exporting teams") 86 if err := a.exportAllTeams(writer); err != nil { 87 return err 88 } 89 90 mlog.Info("Bulk export: exporting channels") 91 if err := a.exportAllChannels(writer); err != nil { 92 return err 93 } 94 95 mlog.Info("Bulk export: exporting users") 96 if err := a.exportAllUsers(writer); err != nil { 97 return err 98 } 99 100 mlog.Info("Bulk export: exporting posts") 101 attachments, err := a.exportAllPosts(writer, opts.IncludeAttachments) 102 if err != nil { 103 return err 104 } 105 106 mlog.Info("Bulk export: exporting emoji") 107 emojiPaths, err := a.exportCustomEmoji(writer, outPath, "exported_emoji", !opts.CreateArchive) 108 if err != nil { 109 return err 110 } 111 112 mlog.Info("Bulk export: exporting direct channels") 113 if err = a.exportAllDirectChannels(writer); err != nil { 114 return err 115 } 116 117 mlog.Info("Bulk export: exporting direct posts") 118 directAttachments, err := a.exportAllDirectPosts(writer, opts.IncludeAttachments) 119 if err != nil { 120 return err 121 } 122 123 if opts.IncludeAttachments { 124 mlog.Info("Bulk export: exporting file attachments") 125 for _, attachment := range attachments { 126 if err := a.exportFile(outPath, *attachment.Path, zipWr); err != nil { 127 return err 128 } 129 } 130 for _, attachment := range directAttachments { 131 if err := a.exportFile(outPath, *attachment.Path, zipWr); err != nil { 132 return err 133 } 134 } 135 for _, emojiPath := range emojiPaths { 136 if err := a.exportFile(outPath, emojiPath, zipWr); err != nil { 137 return err 138 } 139 } 140 } 141 142 return nil 143} 144 145func (a *App) exportWriteLine(writer io.Writer, line *LineImportData) *model.AppError { 146 b, err := json.Marshal(line) 147 if err != nil { 148 return model.NewAppError("BulkExport", "app.export.export_write_line.json_marshall.error", nil, "err="+err.Error(), http.StatusBadRequest) 149 } 150 151 if _, err := writer.Write(append(b, '\n')); err != nil { 152 return model.NewAppError("BulkExport", "app.export.export_write_line.io_writer.error", nil, "err="+err.Error(), http.StatusBadRequest) 153 } 154 155 return nil 156} 157 158func (a *App) exportVersion(writer io.Writer) *model.AppError { 159 version := 1 160 versionLine := &LineImportData{ 161 Type: "version", 162 Version: &version, 163 } 164 165 return a.exportWriteLine(writer, versionLine) 166} 167 168func (a *App) exportAllTeams(writer io.Writer) *model.AppError { 169 afterId := strings.Repeat("0", 26) 170 for { 171 teams, err := a.Srv().Store.Team().GetAllForExportAfter(1000, afterId) 172 if err != nil { 173 return model.NewAppError("exportAllTeams", "app.team.get_all.app_error", nil, err.Error(), http.StatusInternalServerError) 174 } 175 176 if len(teams) == 0 { 177 break 178 } 179 180 for _, team := range teams { 181 afterId = team.Id 182 183 // Skip deleted. 184 if team.DeleteAt != 0 { 185 continue 186 } 187 188 teamLine := ImportLineFromTeam(team) 189 if err := a.exportWriteLine(writer, teamLine); err != nil { 190 return err 191 } 192 } 193 } 194 195 return nil 196} 197 198func (a *App) exportAllChannels(writer io.Writer) *model.AppError { 199 afterId := strings.Repeat("0", 26) 200 for { 201 channels, err := a.Srv().Store.Channel().GetAllChannelsForExportAfter(1000, afterId) 202 203 if err != nil { 204 return model.NewAppError("exportAllChannels", "app.channel.get_all.app_error", nil, err.Error(), http.StatusInternalServerError) 205 } 206 207 if len(channels) == 0 { 208 break 209 } 210 211 for _, channel := range channels { 212 afterId = channel.Id 213 214 // Skip deleted. 215 if channel.DeleteAt != 0 { 216 continue 217 } 218 219 channelLine := ImportLineFromChannel(channel) 220 if err := a.exportWriteLine(writer, channelLine); err != nil { 221 return err 222 } 223 } 224 } 225 226 return nil 227} 228 229func (a *App) exportAllUsers(writer io.Writer) *model.AppError { 230 afterId := strings.Repeat("0", 26) 231 for { 232 users, err := a.Srv().Store.User().GetAllAfter(1000, afterId) 233 234 if err != nil { 235 return model.NewAppError("exportAllUsers", "app.user.get.app_error", nil, err.Error(), http.StatusInternalServerError) 236 } 237 238 if len(users) == 0 { 239 break 240 } 241 242 for _, user := range users { 243 afterId = user.Id 244 245 // Gathering here the exportable preferences to pass them on to ImportLineFromUser 246 exportedPrefs := make(map[string]*string) 247 allPrefs, err := a.GetPreferencesForUser(user.Id) 248 if err != nil { 249 return err 250 } 251 for _, pref := range allPrefs { 252 // We need to manage the special cases 253 // Here we manage Tutorial steps 254 if pref.Category == model.PreferenceCategoryTutorialSteps { 255 pref.Name = "" 256 // Then the email interval 257 } else if pref.Category == model.PreferenceCategoryNotifications && pref.Name == model.PreferenceNameEmailInterval { 258 switch pref.Value { 259 case model.PreferenceEmailIntervalNoBatchingSeconds: 260 pref.Value = model.PreferenceEmailIntervalImmediately 261 case model.PreferenceEmailIntervalFifteenAsSeconds: 262 pref.Value = model.PreferenceEmailIntervalFifteen 263 case model.PreferenceEmailIntervalHourAsSeconds: 264 pref.Value = model.PreferenceEmailIntervalHour 265 case "0": 266 pref.Value = "" 267 } 268 } 269 id, ok := exportablePreferences[ComparablePreference{ 270 Category: pref.Category, 271 Name: pref.Name, 272 }] 273 if ok { 274 prefPtr := pref.Value 275 if prefPtr != "" { 276 exportedPrefs[id] = &prefPtr 277 } else { 278 exportedPrefs[id] = nil 279 } 280 } 281 } 282 283 userLine := ImportLineFromUser(user, exportedPrefs) 284 285 userLine.User.NotifyProps = a.buildUserNotifyProps(user.NotifyProps) 286 287 // Do the Team Memberships. 288 members, err := a.buildUserTeamAndChannelMemberships(user.Id) 289 if err != nil { 290 return err 291 } 292 293 userLine.User.Teams = members 294 295 if err := a.exportWriteLine(writer, userLine); err != nil { 296 return err 297 } 298 } 299 } 300 301 return nil 302} 303 304func (a *App) buildUserTeamAndChannelMemberships(userID string) (*[]UserTeamImportData, *model.AppError) { 305 var memberships []UserTeamImportData 306 307 members, err := a.Srv().Store.Team().GetTeamMembersForExport(userID) 308 309 if err != nil { 310 return nil, model.NewAppError("buildUserTeamAndChannelMemberships", "app.team.get_members.app_error", nil, err.Error(), http.StatusInternalServerError) 311 } 312 313 for _, member := range members { 314 // Skip deleted. 315 if member.DeleteAt != 0 { 316 continue 317 } 318 319 memberData := ImportUserTeamDataFromTeamMember(member) 320 321 // Do the Channel Memberships. 322 channelMembers, err := a.buildUserChannelMemberships(userID, member.TeamId) 323 if err != nil { 324 return nil, err 325 } 326 327 // Get the user theme 328 themePreference, nErr := a.Srv().Store.Preference().Get(member.UserId, model.PreferenceCategoryTheme, member.TeamId) 329 if nErr == nil { 330 memberData.Theme = &themePreference.Value 331 } 332 333 memberData.Channels = channelMembers 334 335 memberships = append(memberships, *memberData) 336 } 337 338 return &memberships, nil 339} 340 341func (a *App) buildUserChannelMemberships(userID string, teamID string) (*[]UserChannelImportData, *model.AppError) { 342 var memberships []UserChannelImportData 343 344 members, nErr := a.Srv().Store.Channel().GetChannelMembersForExport(userID, teamID) 345 if nErr != nil { 346 return nil, model.NewAppError("buildUserChannelMemberships", "app.channel.get_members.app_error", nil, nErr.Error(), http.StatusInternalServerError) 347 } 348 349 category := model.PreferenceCategoryFavoriteChannel 350 preferences, err := a.GetPreferenceByCategoryForUser(userID, category) 351 if err != nil && err.StatusCode != http.StatusNotFound { 352 return nil, err 353 } 354 355 for _, member := range members { 356 memberships = append(memberships, *ImportUserChannelDataFromChannelMemberAndPreferences(member, &preferences)) 357 } 358 return &memberships, nil 359} 360 361func (a *App) buildUserNotifyProps(notifyProps model.StringMap) *UserNotifyPropsImportData { 362 363 getProp := func(key string) *string { 364 if v, ok := notifyProps[key]; ok { 365 return &v 366 } 367 return nil 368 } 369 370 return &UserNotifyPropsImportData{ 371 Desktop: getProp(model.DesktopNotifyProp), 372 DesktopSound: getProp(model.DesktopSoundNotifyProp), 373 Email: getProp(model.EmailNotifyProp), 374 Mobile: getProp(model.PushNotifyProp), 375 MobilePushStatus: getProp(model.PushStatusNotifyProp), 376 ChannelTrigger: getProp(model.ChannelMentionsNotifyProp), 377 CommentsTrigger: getProp(model.CommentsNotifyProp), 378 MentionKeys: getProp(model.MentionKeysNotifyProp), 379 } 380} 381 382func (a *App) exportAllPosts(writer io.Writer, withAttachments bool) ([]AttachmentImportData, *model.AppError) { 383 var attachments []AttachmentImportData 384 afterId := strings.Repeat("0", 26) 385 386 for { 387 posts, nErr := a.Srv().Store.Post().GetParentsForExportAfter(1000, afterId) 388 if nErr != nil { 389 return nil, model.NewAppError("exportAllPosts", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError) 390 } 391 392 if len(posts) == 0 { 393 return attachments, nil 394 } 395 396 for _, post := range posts { 397 afterId = post.Id 398 399 // Skip deleted. 400 if post.DeleteAt != 0 { 401 continue 402 } 403 404 postLine := ImportLineForPost(post) 405 406 replies, replyAttachments, err := a.buildPostReplies(post.Id, withAttachments) 407 if err != nil { 408 return nil, err 409 } 410 411 if withAttachments && len(replyAttachments) > 0 { 412 attachments = append(attachments, replyAttachments...) 413 } 414 415 postLine.Post.Replies = &replies 416 postLine.Post.Reactions = &[]ReactionImportData{} 417 if post.HasReactions { 418 postLine.Post.Reactions, err = a.BuildPostReactions(post.Id) 419 if err != nil { 420 return nil, err 421 } 422 } 423 424 if len(post.FileIds) > 0 { 425 postAttachments, err := a.buildPostAttachments(post.Id) 426 if err != nil { 427 return nil, err 428 } 429 postLine.Post.Attachments = &postAttachments 430 431 if withAttachments && len(postAttachments) > 0 { 432 attachments = append(attachments, postAttachments...) 433 } 434 } 435 436 if err := a.exportWriteLine(writer, postLine); err != nil { 437 return nil, err 438 } 439 } 440 } 441} 442 443func (a *App) buildPostReplies(postID string, withAttachments bool) ([]ReplyImportData, []AttachmentImportData, *model.AppError) { 444 var replies []ReplyImportData 445 var attachments []AttachmentImportData 446 447 replyPosts, nErr := a.Srv().Store.Post().GetRepliesForExport(postID) 448 if nErr != nil { 449 return nil, nil, model.NewAppError("buildPostReplies", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError) 450 } 451 452 for _, reply := range replyPosts { 453 replyImportObject := ImportReplyFromPost(reply) 454 if reply.HasReactions { 455 var appErr *model.AppError 456 replyImportObject.Reactions, appErr = a.BuildPostReactions(reply.Id) 457 if appErr != nil { 458 return nil, nil, appErr 459 } 460 } 461 if len(reply.FileIds) > 0 { 462 postAttachments, appErr := a.buildPostAttachments(reply.Id) 463 if appErr != nil { 464 return nil, nil, appErr 465 } 466 replyImportObject.Attachments = &postAttachments 467 if withAttachments && len(postAttachments) > 0 { 468 attachments = append(attachments, postAttachments...) 469 } 470 } 471 472 replies = append(replies, *replyImportObject) 473 } 474 475 return replies, attachments, nil 476} 477 478func (a *App) BuildPostReactions(postID string) (*[]ReactionImportData, *model.AppError) { 479 var reactionsOfPost []ReactionImportData 480 481 reactions, nErr := a.Srv().Store.Reaction().GetForPost(postID, true) 482 if nErr != nil { 483 return nil, model.NewAppError("BuildPostReactions", "app.reaction.get_for_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) 484 } 485 486 for _, reaction := range reactions { 487 user, err := a.Srv().Store.User().Get(context.Background(), reaction.UserId) 488 if err != nil { 489 var nfErr *store.ErrNotFound 490 if errors.As(err, &nfErr) { // this is a valid case, the user that reacted might've been deleted by now 491 mlog.Info("Skipping reactions by user since the entity doesn't exist anymore", mlog.String("user_id", reaction.UserId)) 492 continue 493 } 494 return nil, model.NewAppError("BuildPostReactions", "app.user.get.app_error", nil, err.Error(), http.StatusInternalServerError) 495 } 496 reactionsOfPost = append(reactionsOfPost, *ImportReactionFromPost(user, reaction)) 497 } 498 499 return &reactionsOfPost, nil 500 501} 502 503func (a *App) buildPostAttachments(postID string) ([]AttachmentImportData, *model.AppError) { 504 infos, nErr := a.Srv().Store.FileInfo().GetForPost(postID, false, false, false) 505 if nErr != nil { 506 return nil, model.NewAppError("buildPostAttachments", "app.file_info.get_for_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) 507 } 508 509 attachments := make([]AttachmentImportData, 0, len(infos)) 510 for _, info := range infos { 511 attachments = append(attachments, AttachmentImportData{Path: &info.Path}) 512 } 513 514 return attachments, nil 515} 516 517func (a *App) exportCustomEmoji(writer io.Writer, outPath, exportDir string, exportFiles bool) ([]string, *model.AppError) { 518 var emojiPaths []string 519 pageNumber := 0 520 for { 521 customEmojiList, err := a.GetEmojiList(pageNumber, 100, model.EmojiSortByName) 522 523 if err != nil { 524 return nil, err 525 } 526 527 if len(customEmojiList) == 0 { 528 break 529 } 530 531 pageNumber++ 532 533 emojiPath := filepath.Join(*a.Config().FileSettings.Directory, "emoji") 534 pathToDir := filepath.Join(outPath, exportDir) 535 if exportFiles { 536 if _, err := os.Stat(pathToDir); os.IsNotExist(err) { 537 os.Mkdir(pathToDir, os.ModePerm) 538 } 539 } 540 541 for _, emoji := range customEmojiList { 542 emojiImagePath := filepath.Join(emojiPath, emoji.Id, "image") 543 filePath := filepath.Join(exportDir, emoji.Id, "image") 544 if exportFiles { 545 err := a.copyEmojiImages(emoji.Id, emojiImagePath, pathToDir) 546 if err != nil { 547 return nil, model.NewAppError("BulkExport", "app.export.export_custom_emoji.copy_emoji_images.error", nil, "err="+err.Error(), http.StatusBadRequest) 548 } 549 } else { 550 filePath = filepath.Join("emoji", emoji.Id, "image") 551 emojiPaths = append(emojiPaths, filePath) 552 } 553 554 emojiImportObject := ImportLineFromEmoji(emoji, filePath) 555 if err := a.exportWriteLine(writer, emojiImportObject); err != nil { 556 return nil, err 557 } 558 } 559 } 560 561 return emojiPaths, nil 562} 563 564// Copies emoji files from 'data/emoji' dir to 'exported_emoji' dir 565func (a *App) copyEmojiImages(emojiId string, emojiImagePath string, pathToDir string) error { 566 fromPath, err := os.Open(emojiImagePath) 567 if fromPath == nil || err != nil { 568 return errors.New("Error reading " + emojiImagePath + "file") 569 } 570 defer fromPath.Close() 571 572 emojiDir := pathToDir + "/" + emojiId 573 574 if _, err = os.Stat(emojiDir); err != nil { 575 if !os.IsNotExist(err) { 576 return errors.Wrapf(err, "Error fetching file info of emoji directory %v", emojiDir) 577 } 578 579 if err = os.Mkdir(emojiDir, os.ModePerm); err != nil { 580 return errors.Wrapf(err, "Error creating emoji directory %v", emojiDir) 581 } 582 } 583 584 toPath, err := os.OpenFile(emojiDir+"/image", os.O_RDWR|os.O_CREATE, 0666) 585 if err != nil { 586 return errors.New("Error creating the image file " + err.Error()) 587 } 588 defer toPath.Close() 589 590 _, err = io.Copy(toPath, fromPath) 591 if err != nil { 592 return errors.New("Error copying emojis " + err.Error()) 593 } 594 595 return nil 596} 597 598func (a *App) exportAllDirectChannels(writer io.Writer) *model.AppError { 599 afterId := strings.Repeat("0", 26) 600 for { 601 channels, err := a.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, afterId) 602 if err != nil { 603 return model.NewAppError("exportAllDirectChannels", "app.channel.get_all_direct.app_error", nil, err.Error(), http.StatusInternalServerError) 604 } 605 606 if len(channels) == 0 { 607 break 608 } 609 610 for _, channel := range channels { 611 afterId = channel.Id 612 613 // Skip if there are no active members in the channel 614 if len(*channel.Members) == 0 { 615 continue 616 } 617 618 // Skip deleted. 619 if channel.DeleteAt != 0 { 620 continue 621 } 622 623 channelLine := ImportLineFromDirectChannel(channel) 624 if err := a.exportWriteLine(writer, channelLine); err != nil { 625 return err 626 } 627 } 628 } 629 630 return nil 631} 632 633func (a *App) exportAllDirectPosts(writer io.Writer, withAttachments bool) ([]AttachmentImportData, *model.AppError) { 634 var attachments []AttachmentImportData 635 afterId := strings.Repeat("0", 26) 636 for { 637 posts, err := a.Srv().Store.Post().GetDirectPostParentsForExportAfter(1000, afterId) 638 if err != nil { 639 return nil, model.NewAppError("exportAllDirectPosts", "app.post.get_direct_posts.app_error", nil, err.Error(), http.StatusInternalServerError) 640 } 641 642 if len(posts) == 0 { 643 break 644 } 645 646 for _, post := range posts { 647 afterId = post.Id 648 649 // Skip deleted. 650 if post.DeleteAt != 0 { 651 continue 652 } 653 654 // Handle attachments. 655 var postAttachments []AttachmentImportData 656 var err *model.AppError 657 if len(post.FileIds) > 0 { 658 postAttachments, err = a.buildPostAttachments(post.Id) 659 if err != nil { 660 return nil, err 661 } 662 663 if withAttachments && len(postAttachments) > 0 { 664 attachments = append(attachments, postAttachments...) 665 } 666 } 667 668 // Do the Replies. 669 replies, replyAttachments, err := a.buildPostReplies(post.Id, withAttachments) 670 if err != nil { 671 return nil, err 672 } 673 674 if withAttachments && len(replyAttachments) > 0 { 675 attachments = append(attachments, replyAttachments...) 676 } 677 678 postLine := ImportLineForDirectPost(post) 679 postLine.DirectPost.Replies = &replies 680 if len(postAttachments) > 0 { 681 postLine.DirectPost.Attachments = &postAttachments 682 } 683 if err := a.exportWriteLine(writer, postLine); err != nil { 684 return nil, err 685 } 686 } 687 } 688 return attachments, nil 689} 690 691func (a *App) exportFile(outPath, filePath string, zipWr *zip.Writer) *model.AppError { 692 var wr io.Writer 693 var err error 694 rd, appErr := a.FileReader(filePath) 695 if appErr != nil { 696 return appErr 697 } 698 defer rd.Close() 699 700 if zipWr != nil { 701 wr, err = zipWr.CreateHeader(&zip.FileHeader{ 702 Name: filepath.Join(ExportDataDir, filePath), 703 Method: zip.Store, 704 }) 705 if err != nil { 706 return model.NewAppError("exportFileAttachment", "app.export.export_attachment.zip_create_header.error", 707 nil, "err="+err.Error(), http.StatusInternalServerError) 708 } 709 } else { 710 filePath = filepath.Join(outPath, ExportDataDir, filePath) 711 if err = os.MkdirAll(filepath.Dir(filePath), 0700); err != nil { 712 return model.NewAppError("exportFileAttachment", "app.export.export_attachment.mkdirall.error", 713 nil, "err="+err.Error(), http.StatusInternalServerError) 714 } 715 716 wr, err = os.Create(filePath) 717 if err != nil { 718 return model.NewAppError("exportFileAttachment", "app.export.export_attachment.create_file.error", 719 nil, "err="+err.Error(), http.StatusInternalServerError) 720 } 721 defer wr.(*os.File).Close() 722 } 723 724 if _, err := io.Copy(wr, rd); err != nil { 725 return model.NewAppError("exportFileAttachment", "app.export.export_attachment.copy_file.error", 726 nil, "err="+err.Error(), http.StatusInternalServerError) 727 } 728 729 return nil 730} 731 732func (a *App) ListExports() ([]string, *model.AppError) { 733 exports, appErr := a.ListDirectory(*a.Config().ExportSettings.Directory) 734 if appErr != nil { 735 return nil, appErr 736 } 737 738 results := make([]string, len(exports)) 739 for i := range exports { 740 results[i] = filepath.Base(exports[i]) 741 } 742 743 return results, nil 744} 745 746func (a *App) DeleteExport(name string) *model.AppError { 747 filePath := filepath.Join(*a.Config().ExportSettings.Directory, name) 748 749 if ok, err := a.FileExists(filePath); err != nil { 750 return err 751 } else if !ok { 752 return nil 753 } 754 755 return a.RemoveFile(filePath) 756} 757