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