[MM-31247] Add support for compressed export files with attachments (#16614)

* Include filepaths for post attachments

* Cleanup

* Enable exporting file attachments

* Fix file import

* Enable zip export

* Support creating missing directories when unzipping

* Add test

* Add translations

* Export direct channel posts attachments

* Fix returned values order

Remove pointer to slice in return

* [MM-31597] Implement export process job (#16626)

* Implement export process job

* Add translations

* Remove unused value

* [MM-31249] Add /exports API endpoint (#16633)

* Implement API endpoints to list, download and delete export files

* Add endpoint for single resource

* Update i18n/en.json

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>

* Update i18n/en.json

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>

* Fix var name

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
Этот коммит содержится в:
Claudio Costa
2021-02-09 11:58:31 +01:00
коммит произвёл GitHub
родитель 9a33c3706a
Коммит 572f861675
27 изменённых файлов: 1063 добавлений и 106 удалений

Просмотреть файл

@@ -125,6 +125,8 @@ type Routes struct {
Cloud *mux.Router // 'api/v4/cloud' Cloud *mux.Router // 'api/v4/cloud'
Imports *mux.Router // 'api/v4/imports' Imports *mux.Router // 'api/v4/imports'
Exports *mux.Router // 'api/v4/exports'
Export *mux.Router // 'api/v4/exports/{export_name:.+\\.zip}'
} }
type API struct { type API struct {
@@ -238,6 +240,8 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp
api.BaseRoutes.Cloud = api.BaseRoutes.ApiRoot.PathPrefix("/cloud").Subrouter() api.BaseRoutes.Cloud = api.BaseRoutes.ApiRoot.PathPrefix("/cloud").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.ApiRoot.PathPrefix("/imports").Subrouter() api.BaseRoutes.Imports = api.BaseRoutes.ApiRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.ApiRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.InitUser() api.InitUser()
api.InitBot() api.InitBot()
@@ -276,6 +280,7 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp
api.InitAction() api.InitAction()
api.InitCloud() api.InitCloud()
api.InitImport() api.InitImport()
api.InitExport()
root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
@@ -344,6 +349,8 @@ func InitLocal(configservice configservice.ConfigService, globalOptionsFunc app.
api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.ApiRoot.PathPrefix("/imports").Subrouter() api.BaseRoutes.Imports = api.BaseRoutes.ApiRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.ApiRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.ApiRoot.PathPrefix("/jobs").Subrouter() api.BaseRoutes.Jobs = api.BaseRoutes.ApiRoot.PathPrefix("/jobs").Subrouter()
@@ -363,6 +370,7 @@ func InitLocal(configservice configservice.ConfigService, globalOptionsFunc app.
api.InitRoleLocal() api.InitRoleLocal()
api.InitUploadLocal() api.InitUploadLocal()
api.InitImportLocal() api.InitImportLocal()
api.InitExportLocal()
api.InitJobLocal() api.InitJobLocal()
root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))

86
api4/export.go Обычный файл
Просмотреть файл

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"path/filepath"
"time"
"github.com/mattermost/mattermost-server/v5/audit"
"github.com/mattermost/mattermost-server/v5/model"
)
func (api *API) InitExport() {
api.BaseRoutes.Exports.Handle("", api.ApiSessionRequired(listExports)).Methods("GET")
api.BaseRoutes.Export.Handle("", api.ApiSessionRequired(deleteExport)).Methods("DELETE")
api.BaseRoutes.Export.Handle("", api.ApiSessionRequired(downloadExport)).Methods("GET")
}
func listExports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM)
return
}
exports, appErr := c.App.ListExports()
if appErr != nil {
c.Err = appErr
return
}
data, err := json.Marshal(exports)
if err != nil {
c.Err = model.NewAppError("listImports", "app.export.marshal.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
w.Write(data)
}
func deleteExport(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("deleteExport", audit.Fail)
defer c.LogAuditRec(auditRec)
auditRec.AddMeta("export_name", c.Params.ExportName)
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM)
return
}
if err := c.App.DeleteExport(c.Params.ExportName); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func downloadExport(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM)
return
}
filePath := filepath.Join(*c.App.Config().ExportSettings.Directory, c.Params.ExportName)
if ok, err := c.App.FileExists(filePath); err != nil {
c.Err = err
return
} else if !ok {
c.Err = model.NewAppError("downloadExport", "api.export.export_not_found.app_error", nil, "", http.StatusNotFound)
return
}
file, err := c.App.FileReader(filePath)
if err != nil {
c.Err = err
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/zip")
http.ServeContent(w, r, c.Params.ExportName, time.Time{}, file)
}

10
api4/export_local.go Обычный файл
Просмотреть файл

@@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
func (api *API) InitExportLocal() {
api.BaseRoutes.Exports.Handle("", api.ApiLocal(listExports)).Methods("GET")
api.BaseRoutes.Export.Handle("", api.ApiLocal(deleteExport)).Methods("DELETE")
api.BaseRoutes.Export.Handle("", api.ApiLocal(downloadExport)).Methods("GET")
}

213
api4/export_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/utils/fileutils"
"github.com/stretchr/testify/require"
)
func TestListExports(t *testing.T) {
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
exports, resp := th.Client.ListExports()
require.Error(t, resp.Error)
require.Equal(t, "api.context.permissions.app_error", resp.Error.Id)
require.Nil(t, exports)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exports, resp := c.ListExports()
require.Nil(t, resp.Error)
require.Empty(t, exports)
}, "no exports")
dataDir, found := fileutils.FindDir("data")
require.True(t, found)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.Nil(t, err)
defer os.RemoveAll(exportDir)
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.Nil(t, err)
f.Close()
exports, resp := c.ListExports()
require.Nil(t, resp.Error)
require.Len(t, exports, 1)
require.Equal(t, exports[0], "export.zip")
}, "expected exports")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
value := *th.App.Config().ExportSettings.Directory
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExportSettings.Directory = value + "new" })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExportSettings.Directory = value })
exportDir := filepath.Join(dataDir, value+"new")
err := os.Mkdir(exportDir, 0700)
require.Nil(t, err)
defer os.RemoveAll(exportDir)
exports, resp := c.ListExports()
require.Nil(t, resp.Error)
require.Empty(t, exports)
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.Nil(t, err)
f.Close()
exports, resp = c.ListExports()
require.Nil(t, resp.Error)
require.Len(t, exports, 1)
require.Equal(t, "export.zip", exports[0])
}, "change export directory")
}
func TestDeleteExport(t *testing.T) {
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
ok, resp := th.Client.DeleteExport("export.zip")
require.Error(t, resp.Error)
require.Equal(t, "api.context.permissions.app_error", resp.Error.Id)
require.False(t, ok)
})
dataDir, found := fileutils.FindDir("data")
require.True(t, found)
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.Nil(t, err)
defer os.RemoveAll(exportDir)
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.Nil(t, err)
f.Close()
exports, resp := c.ListExports()
require.Nil(t, resp.Error)
require.Len(t, exports, 1)
require.Equal(t, exports[0], exportName)
ok, resp := c.DeleteExport(exportName)
require.Nil(t, resp.Error)
require.True(t, ok)
exports, resp = c.ListExports()
require.Nil(t, resp.Error)
require.Empty(t, exports)
// verify idempotence
ok, resp = c.DeleteExport(exportName)
require.Nil(t, resp.Error)
require.True(t, ok)
}, "successfully delete export")
}
func TestDownloadExport(t *testing.T) {
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
var buf bytes.Buffer
n, resp := th.Client.DownloadExport("export.zip", &buf, 0)
require.Error(t, resp.Error)
require.Equal(t, "api.context.permissions.app_error", resp.Error.Id)
require.Zero(t, n)
})
dataDir, found := fileutils.FindDir("data")
require.True(t, found)
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
var buf bytes.Buffer
n, resp := c.DownloadExport("export.zip", &buf, 0)
require.Error(t, resp.Error)
require.Equal(t, "api.export.export_not_found.app_error", resp.Error.Id)
require.Zero(t, n)
}, "not found")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.Nil(t, err)
defer os.RemoveAll(exportDir)
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = ioutil.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.Nil(t, err)
n, resp := c.DownloadExport(exportName, &buf, 0)
require.Nil(t, resp.Error)
require.Equal(t, len(data), int(n))
require.Equal(t, data, buf.Bytes())
}, "full download")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.Nil(t, err)
defer os.RemoveAll(exportDir)
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = ioutil.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.Nil(t, err)
offset := 1024 * 512
n, resp := c.DownloadExport(exportName, &buf, int64(offset))
require.Nil(t, resp.Error)
require.Equal(t, len(data)-offset, int(n))
require.Equal(t, data[offset:], buf.Bytes())
}, "download with offset")
}
func BenchmarkDownloadExport(b *testing.B) {
th := Setup(b)
defer th.TearDown()
dataDir, found := fileutils.FindDir("data")
require.True(b, found)
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.Nil(b, err)
defer os.RemoveAll(exportDir)
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.Nil(b, err)
f.Close()
err = os.Truncate(filepath.Join(exportDir, exportName), 1024*1024*1024)
require.Nil(b, err)
b.ResetTimer()
for i := 0; i < b.N; i++ {
outFilePath := filepath.Join(dataDir, fmt.Sprintf("export%d.zip", i))
outFile, _ := os.Create(outFilePath)
th.SystemAdminClient.DownloadExport(exportName, outFile, 0)
outFile.Close()
os.Remove(outFilePath)
}
}

Просмотреть файл

@@ -123,6 +123,10 @@ func (a *App) initJobs() {
a.srv.Jobs.ImportDelete = jobsImportDeleteInterface(a) a.srv.Jobs.ImportDelete = jobsImportDeleteInterface(a)
} }
if jobsExportProcessInterface != nil {
a.srv.Jobs.ExportProcess = jobsExportProcessInterface(a)
}
if jobsActiveUsersInterface != nil { if jobsActiveUsersInterface != nil {
a.srv.Jobs.ActiveUsers = jobsActiveUsersInterface(a) a.srv.Jobs.ActiveUsers = jobsActiveUsersInterface(a)
} }

Просмотреть файл

@@ -388,7 +388,7 @@ type AppIface interface {
BuildPostReactions(postId string) (*[]ReactionImportData, *model.AppError) BuildPostReactions(postId string) (*[]ReactionImportData, *model.AppError)
BuildPushNotificationMessage(contentsConfig string, post *model.Post, user *model.User, channel *model.Channel, channelName string, senderName string, explicitMention bool, channelWideMention bool, replyToThreadType string) (*model.PushNotification, *model.AppError) BuildPushNotificationMessage(contentsConfig string, post *model.Post, user *model.User, channel *model.Channel, channelName string, senderName string, explicitMention bool, channelWideMention bool, replyToThreadType string) (*model.PushNotification, *model.AppError)
BuildSamlMetadataObject(idpMetadata []byte) (*model.SamlMetadataResponse, *model.AppError) BuildSamlMetadataObject(idpMetadata []byte) (*model.SamlMetadataResponse, *model.AppError)
BulkExport(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError BulkExport(writer io.Writer, outPath string, opts BulkExportOpts) *model.AppError
BulkImport(fileReader io.Reader, dryRun bool, workers int) (*model.AppError, int) BulkImport(fileReader io.Reader, dryRun bool, workers int) (*model.AppError, int)
BulkImportWithPath(fileReader io.Reader, dryRun bool, workers int, importPath string) (*model.AppError, int) BulkImportWithPath(fileReader io.Reader, dryRun bool, workers int, importPath string) (*model.AppError, int)
CancelJob(jobId string) *model.AppError CancelJob(jobId string) *model.AppError
@@ -466,6 +466,7 @@ type AppIface interface {
DeleteCommand(commandID string) *model.AppError DeleteCommand(commandID string) *model.AppError
DeleteEmoji(emoji *model.Emoji) *model.AppError DeleteEmoji(emoji *model.Emoji) *model.AppError
DeleteEphemeralPost(userID, postId string) DeleteEphemeralPost(userID, postId string)
DeleteExport(name string) *model.AppError
DeleteFlaggedPosts(postId string) DeleteFlaggedPosts(postId string)
DeleteGroup(groupID string) (*model.Group, *model.AppError) DeleteGroup(groupID string) (*model.Group, *model.AppError)
DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError)
@@ -782,6 +783,7 @@ type AppIface interface {
LimitedClientConfig() map[string]string LimitedClientConfig() map[string]string
ListAllCommands(teamID string, T goi18n.TranslateFunc) ([]*model.Command, *model.AppError) ListAllCommands(teamID string, T goi18n.TranslateFunc) ([]*model.Command, *model.AppError)
ListDirectory(path string) ([]string, *model.AppError) ListDirectory(path string) ([]string, *model.AppError)
ListExports() ([]string, *model.AppError)
ListImports() ([]string, *model.AppError) ListImports() ([]string, *model.AppError)
ListPluginKeys(pluginId string, page, perPage int) ([]string, *model.AppError) ListPluginKeys(pluginId string, page, perPage int) ([]string, *model.AppError)
ListTeamCommands(teamID string) ([]*model.Command, *model.AppError) ListTeamCommands(teamID string) ([]*model.Command, *model.AppError)

Просмотреть файл

@@ -120,6 +120,12 @@ func RegisterJobsImportDeleteInterface(f func(*App) tjobs.ImportDeleteInterface)
jobsImportDeleteInterface = f jobsImportDeleteInterface = f
} }
var jobsExportProcessInterface func(*App) tjobs.ExportProcessInterface
func RegisterJobsExportProcessInterface(f func(*App) tjobs.ExportProcessInterface) {
jobsExportProcessInterface = f
}
var productNoticesJobInterface func(*App) tjobs.ProductNoticesJobInterface var productNoticesJobInterface func(*App) tjobs.ProductNoticesJobInterface
func RegisterProductNoticesJobInterface(f func(*App) tjobs.ProductNoticesJobInterface) { func RegisterProductNoticesJobInterface(f func(*App) tjobs.ProductNoticesJobInterface) {

Просмотреть файл

@@ -4,6 +4,7 @@
package app package app
import ( import (
"archive/zip"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
@@ -18,6 +19,15 @@ import (
"github.com/mattermost/mattermost-server/v5/store" "github.com/mattermost/mattermost-server/v5/store"
) )
type BulkExportOpts struct {
IncludeAttachments bool
CreateArchive bool
}
// ExportDataDir is the name of the directory were to store additional data
// included with the export (e.g. file attachments).
const ExportDataDir = "data"
// We use this map to identify the exportable preferences. // We use this map to identify the exportable preferences.
// Here we link the preference category and name, to the name of the relevant field in the import struct. // Here we link the preference category and name, to the name of the relevant field in the import struct.
var exportablePreferences = map[ComparablePreference]string{{ var exportablePreferences = map[ComparablePreference]string{{
@@ -53,7 +63,19 @@ var exportablePreferences = map[ComparablePreference]string{{
}: "EmailInterval", }: "EmailInterval",
} }
func (a *App) BulkExport(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError { func (a *App) BulkExport(writer io.Writer, outPath string, opts BulkExportOpts) *model.AppError {
var zipWr *zip.Writer
if opts.CreateArchive {
var err error
zipWr = zip.NewWriter(writer)
defer zipWr.Close()
writer, err = zipWr.Create("import.jsonl")
if err != nil {
return model.NewAppError("BulkExport", "app.export.zip_create.error",
nil, "err="+err.Error(), http.StatusInternalServerError)
}
}
mlog.Info("Bulk export: exporting version") mlog.Info("Bulk export: exporting version")
if err := a.exportVersion(writer); err != nil { if err := a.exportVersion(writer); err != nil {
return err return err
@@ -75,25 +97,47 @@ func (a *App) BulkExport(writer io.Writer, file string, pathToEmojiDir string, d
} }
mlog.Info("Bulk export: exporting posts") mlog.Info("Bulk export: exporting posts")
if err := a.exportAllPosts(writer); err != nil { attachments, err := a.exportAllPosts(writer, opts.IncludeAttachments)
if err != nil {
return err return err
} }
mlog.Info("Bulk export: exporting emoji") mlog.Info("Bulk export: exporting emoji")
if err := a.exportCustomEmoji(writer, file, pathToEmojiDir, dirNameToExportEmoji); err != nil { emojiPaths, err := a.exportCustomEmoji(writer, outPath, "exported_emoji", !opts.CreateArchive)
if err != nil {
return err return err
} }
mlog.Info("Bulk export: exporting direct channels") mlog.Info("Bulk export: exporting direct channels")
if err := a.exportAllDirectChannels(writer); err != nil { if err = a.exportAllDirectChannels(writer); err != nil {
return err return err
} }
mlog.Info("Bulk export: exporting direct posts") mlog.Info("Bulk export: exporting direct posts")
if err := a.exportAllDirectPosts(writer); err != nil { directAttachments, err := a.exportAllDirectPosts(writer, opts.IncludeAttachments)
if err != nil {
return err return err
} }
if opts.IncludeAttachments {
mlog.Info("Bulk export: exporting file attachments")
for _, attachment := range attachments {
if err := a.exportFile(outPath, *attachment.Path, zipWr); err != nil {
return err
}
}
for _, attachment := range directAttachments {
if err := a.exportFile(outPath, *attachment.Path, zipWr); err != nil {
return err
}
}
for _, emojiPath := range emojiPaths {
if err := a.exportFile(outPath, emojiPath, zipWr); err != nil {
return err
}
}
}
return nil return nil
} }
@@ -334,17 +378,18 @@ func (a *App) buildUserNotifyProps(notifyProps model.StringMap) *UserNotifyProps
} }
} }
func (a *App) exportAllPosts(writer io.Writer) *model.AppError { func (a *App) exportAllPosts(writer io.Writer, withAttachments bool) ([]AttachmentImportData, *model.AppError) {
var attachments []AttachmentImportData
afterId := strings.Repeat("0", 26) afterId := strings.Repeat("0", 26)
for { for {
posts, nErr := a.Srv().Store.Post().GetParentsForExportAfter(1000, afterId) posts, nErr := a.Srv().Store.Post().GetParentsForExportAfter(1000, afterId)
if nErr != nil { if nErr != nil {
return model.NewAppError("exportAllPosts", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError) return nil, model.NewAppError("exportAllPosts", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError)
} }
if len(posts) == 0 { if len(posts) == 0 {
return nil return attachments, nil
} }
for _, post := range posts { for _, post := range posts {
@@ -357,33 +402,50 @@ func (a *App) exportAllPosts(writer io.Writer) *model.AppError {
postLine := ImportLineForPost(post) postLine := ImportLineForPost(post)
var err *model.AppError replies, replyAttachments, err := a.buildPostReplies(post.Id, withAttachments)
postLine.Post.Replies, err = a.buildPostReplies(post.Id)
if err != nil { if err != nil {
return err return nil, err
} }
if withAttachments && len(replyAttachments) > 0 {
attachments = append(attachments, replyAttachments...)
}
postLine.Post.Replies = &replies
postLine.Post.Reactions = &[]ReactionImportData{} postLine.Post.Reactions = &[]ReactionImportData{}
if post.HasReactions { if post.HasReactions {
postLine.Post.Reactions, err = a.BuildPostReactions(post.Id) postLine.Post.Reactions, err = a.BuildPostReactions(post.Id)
if err != nil { if err != nil {
return err return nil, err
}
}
if len(post.FileIds) > 0 {
postAttachments, err := a.buildPostAttachments(post.Id)
if err != nil {
return nil, err
}
postLine.Post.Attachments = &postAttachments
if withAttachments && len(postAttachments) > 0 {
attachments = append(attachments, postAttachments...)
} }
} }
if err := a.exportWriteLine(writer, postLine); err != nil { if err := a.exportWriteLine(writer, postLine); err != nil {
return err return nil, err
} }
} }
} }
} }
func (a *App) buildPostReplies(postId string) (*[]ReplyImportData, *model.AppError) { func (a *App) buildPostReplies(postId string, withAttachments bool) ([]ReplyImportData, []AttachmentImportData, *model.AppError) {
var replies []ReplyImportData var replies []ReplyImportData
var attachments []AttachmentImportData
replyPosts, nErr := a.Srv().Store.Post().GetRepliesForExport(postId) replyPosts, nErr := a.Srv().Store.Post().GetRepliesForExport(postId)
if nErr != nil { if nErr != nil {
return nil, model.NewAppError("buildPostReplies", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError) return nil, nil, model.NewAppError("buildPostReplies", "app.post.get_posts.app_error", nil, nErr.Error(), http.StatusInternalServerError)
} }
for _, reply := range replyPosts { for _, reply := range replyPosts {
@@ -392,13 +454,24 @@ func (a *App) buildPostReplies(postId string) (*[]ReplyImportData, *model.AppErr
var appErr *model.AppError var appErr *model.AppError
replyImportObject.Reactions, appErr = a.BuildPostReactions(reply.Id) replyImportObject.Reactions, appErr = a.BuildPostReactions(reply.Id)
if appErr != nil { if appErr != nil {
return nil, appErr return nil, nil, appErr
} }
} }
if len(reply.FileIds) > 0 {
postAttachments, appErr := a.buildPostAttachments(reply.Id)
if appErr != nil {
return nil, nil, appErr
}
replyImportObject.Attachments = &attachments
if withAttachments && len(postAttachments) > 0 {
attachments = append(attachments, postAttachments...)
}
}
replies = append(replies, *replyImportObject) replies = append(replies, *replyImportObject)
} }
return &replies, nil return replies, attachments, nil
} }
func (a *App) BuildPostReactions(postId string) (*[]ReactionImportData, *model.AppError) { func (a *App) BuildPostReactions(postId string) (*[]ReactionImportData, *model.AppError) {
@@ -426,13 +499,28 @@ func (a *App) BuildPostReactions(postId string) (*[]ReactionImportData, *model.A
} }
func (a *App) exportCustomEmoji(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError { func (a *App) buildPostAttachments(postId string) ([]AttachmentImportData, *model.AppError) {
infos, nErr := a.Srv().Store.FileInfo().GetForPost(postId, false, false, false)
if nErr != nil {
return nil, model.NewAppError("buildPostAttachments", "app.file_info.get_for_post.app_error", nil, nErr.Error(), http.StatusInternalServerError)
}
attachments := make([]AttachmentImportData, 0, len(infos))
for _, info := range infos {
attachments = append(attachments, AttachmentImportData{Path: &info.Path})
}
return attachments, nil
}
func (a *App) exportCustomEmoji(writer io.Writer, outPath, exportDir string, exportFiles bool) ([]string, *model.AppError) {
var emojiPaths []string
pageNumber := 0 pageNumber := 0
for { for {
customEmojiList, err := a.GetEmojiList(pageNumber, 100, model.EMOJI_SORT_BY_NAME) customEmojiList, err := a.GetEmojiList(pageNumber, 100, model.EMOJI_SORT_BY_NAME)
if err != nil { if err != nil {
return err return nil, err
} }
if len(customEmojiList) == 0 { if len(customEmojiList) == 0 {
@@ -441,42 +529,35 @@ func (a *App) exportCustomEmoji(writer io.Writer, file string, pathToEmojiDir st
pageNumber++ pageNumber++
pathToDir := a.createDirForEmoji(file, dirNameToExportEmoji) emojiPath := filepath.Join(*a.Config().FileSettings.Directory, "emoji")
pathToDir := filepath.Join(outPath, exportDir)
if exportFiles {
if _, err := os.Stat(pathToDir); os.IsNotExist(err) {
os.Mkdir(pathToDir, os.ModePerm)
}
}
for _, emoji := range customEmojiList { for _, emoji := range customEmojiList {
emojiImagePath := pathToEmojiDir + emoji.Id + "/image" emojiImagePath := filepath.Join(emojiPath, emoji.Id, "image")
err := a.copyEmojiImages(emoji.Id, emojiImagePath, pathToDir) filePath := filepath.Join(exportDir, emoji.Id, "image")
if err != nil { if exportFiles {
return model.NewAppError("BulkExport", "app.export.export_custom_emoji.copy_emoji_images.error", nil, "err="+err.Error(), http.StatusBadRequest) err := a.copyEmojiImages(emoji.Id, emojiImagePath, pathToDir)
if err != nil {
return nil, model.NewAppError("BulkExport", "app.export.export_custom_emoji.copy_emoji_images.error", nil, "err="+err.Error(), http.StatusBadRequest)
}
} else {
filePath = filepath.Join("emoji", emoji.Id, "image")
emojiPaths = append(emojiPaths, filePath)
} }
filePath := dirNameToExportEmoji + "/" + emoji.Id + "/image"
emojiImportObject := ImportLineFromEmoji(emoji, filePath) emojiImportObject := ImportLineFromEmoji(emoji, filePath)
if err := a.exportWriteLine(writer, emojiImportObject); err != nil { if err := a.exportWriteLine(writer, emojiImportObject); err != nil {
return err return nil, err
} }
} }
} }
return nil return emojiPaths, nil
}
// Creates directory named 'exported_emoji' to copy the emoji files
// Directory and the file specified by admin share the same path
func (a *App) createDirForEmoji(file string, dirName string) string {
pathToFile, _ := filepath.Abs(file)
pathSlice := strings.Split(pathToFile, "/")
if len(pathSlice) > 0 {
pathSlice = pathSlice[:len(pathSlice)-1]
}
pathToDir := strings.Join(pathSlice, "/") + "/" + dirName
if _, err := os.Stat(pathToDir); os.IsNotExist(err) {
os.Mkdir(pathToDir, os.ModePerm)
}
return pathToDir
} }
// Copies emoji files from 'data/emoji' dir to 'exported_emoji' dir // Copies emoji files from 'data/emoji' dir to 'exported_emoji' dir
@@ -543,12 +624,13 @@ func (a *App) exportAllDirectChannels(writer io.Writer) *model.AppError {
return nil return nil
} }
func (a *App) exportAllDirectPosts(writer io.Writer) *model.AppError { func (a *App) exportAllDirectPosts(writer io.Writer, withAttachments bool) ([]AttachmentImportData, *model.AppError) {
var attachments []AttachmentImportData
afterId := strings.Repeat("0", 26) afterId := strings.Repeat("0", 26)
for { for {
posts, err := a.Srv().Store.Post().GetDirectPostParentsForExportAfter(1000, afterId) posts, err := a.Srv().Store.Post().GetDirectPostParentsForExportAfter(1000, afterId)
if err != nil { if err != nil {
return model.NewAppError("exportAllDirectPosts", "app.post.get_direct_posts.app_error", nil, err.Error(), http.StatusInternalServerError) return nil, model.NewAppError("exportAllDirectPosts", "app.post.get_direct_posts.app_error", nil, err.Error(), http.StatusInternalServerError)
} }
if len(posts) == 0 { if len(posts) == 0 {
@@ -563,18 +645,106 @@ func (a *App) exportAllDirectPosts(writer io.Writer) *model.AppError {
continue continue
} }
// Handle attachments.
var postAttachments []AttachmentImportData
var err *model.AppError
if len(post.FileIds) > 0 {
postAttachments, err = a.buildPostAttachments(post.Id)
if err != nil {
return nil, err
}
if withAttachments && len(postAttachments) > 0 {
attachments = append(attachments, postAttachments...)
}
}
// Do the Replies. // Do the Replies.
replies, err := a.buildPostReplies(post.Id) replies, replyAttachments, err := a.buildPostReplies(post.Id, withAttachments)
if err != nil { if err != nil {
return err return nil, err
}
if withAttachments && len(replyAttachments) > 0 {
attachments = append(attachments, replyAttachments...)
} }
postLine := ImportLineForDirectPost(post) postLine := ImportLineForDirectPost(post)
postLine.DirectPost.Replies = replies postLine.DirectPost.Replies = &replies
if len(postAttachments) > 0 {
postLine.DirectPost.Attachments = &postAttachments
}
if err := a.exportWriteLine(writer, postLine); err != nil { if err := a.exportWriteLine(writer, postLine); err != nil {
return err return nil, err
} }
} }
} }
return attachments, nil
}
func (a *App) exportFile(outPath, filePath string, zipWr *zip.Writer) *model.AppError {
var wr io.Writer
var err error
rd, appErr := a.FileReader(filePath)
if appErr != nil {
return appErr
}
defer rd.Close()
if zipWr != nil {
wr, err = zipWr.CreateHeader(&zip.FileHeader{
Name: filepath.Join(ExportDataDir, filePath),
Method: zip.Store,
})
if err != nil {
return model.NewAppError("exportFileAttachment", "app.export.export_attachment.zip_create_header.error",
nil, "err="+err.Error(), http.StatusInternalServerError)
}
} else {
filePath = filepath.Join(outPath, ExportDataDir, filePath)
if err = os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
return model.NewAppError("exportFileAttachment", "app.export.export_attachment.mkdirall.error",
nil, "err="+err.Error(), http.StatusInternalServerError)
}
wr, err = os.Create(filePath)
if err != nil {
return model.NewAppError("exportFileAttachment", "app.export.export_attachment.create_file.error",
nil, "err="+err.Error(), http.StatusInternalServerError)
}
defer wr.(*os.File).Close()
}
if _, err := io.Copy(wr, rd); err != nil {
return model.NewAppError("exportFileAttachment", "app.export.export_attachment.copy_file.error",
nil, "err="+err.Error(), http.StatusInternalServerError)
}
return nil return nil
} }
func (a *App) ListExports() ([]string, *model.AppError) {
exports, appErr := a.ListDirectory(*a.Config().ExportSettings.Directory)
if appErr != nil {
return nil, appErr
}
results := make([]string, len(exports))
for i := range exports {
results[i] = filepath.Base(exports[i])
}
return results, nil
}
func (a *App) DeleteExport(name string) *model.AppError {
filePath := filepath.Join(*a.Config().ExportSettings.Directory, name)
if ok, err := a.FileExists(filePath); err != nil {
return err
} else if !ok {
return nil
}
return a.RemoveFile(filePath)
}

Просмотреть файл

@@ -5,7 +5,9 @@ package app
import ( import (
"bytes" "bytes"
"io/ioutil"
"os" "os"
"path/filepath"
"sort" "sort"
"testing" "testing"
@@ -13,6 +15,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/utils"
"github.com/mattermost/mattermost-server/v5/utils/fileutils"
) )
func TestReactionsOfPost(t *testing.T) { func TestReactionsOfPost(t *testing.T) {
@@ -111,16 +115,6 @@ func TestExportUserChannels(t *testing.T) {
} }
} }
func TestDirCreationForEmoji(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
pathToDir := th.App.createDirForEmoji("test.json", "exported_emoji_test")
defer os.Remove(pathToDir)
_, err := os.Stat(pathToDir)
require.False(t, os.IsNotExist(err), "Directory exported_emoji_test should exist")
}
func TestCopyEmojiImages(t *testing.T) { func TestCopyEmojiImages(t *testing.T) {
th := SetupWithStoreMock(t) th := SetupWithStoreMock(t)
defer th.TearDown() defer th.TearDown()
@@ -164,11 +158,13 @@ func TestExportCustomEmoji(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
defer os.Remove(filePath) defer os.Remove(filePath)
pathToEmojiDir := "../data/emoji/"
dirNameToExportEmoji := "exported_emoji_test" dirNameToExportEmoji := "exported_emoji_test"
defer os.RemoveAll("../" + dirNameToExportEmoji) defer os.RemoveAll("../" + dirNameToExportEmoji)
err = th.App.exportCustomEmoji(fileWriter, filePath, pathToEmojiDir, dirNameToExportEmoji) outPath, err := filepath.Abs(filePath)
require.Nil(t, err)
_, err = th.App.exportCustomEmoji(fileWriter, outPath, dirNameToExportEmoji, false)
require.Nil(t, err, "should not have failed") require.Nil(t, err, "should not have failed")
} }
@@ -182,7 +178,7 @@ func TestExportAllUsers(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
var b bytes.Buffer var b bytes.Buffer
err = th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err = th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
th2 := Setup(t) th2 := Setup(t)
@@ -228,7 +224,7 @@ func TestExportDMChannel(t *testing.T) {
th1.CreateDmChannel(th1.BasicUser2) th1.CreateDmChannel(th1.BasicUser2)
var b bytes.Buffer var b bytes.Buffer
err := th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err := th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000") channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000")
@@ -264,7 +260,7 @@ func TestExportDMChannelToSelf(t *testing.T) {
th1.CreateDmChannel(th1.BasicUser) th1.CreateDmChannel(th1.BasicUser)
var b bytes.Buffer var b bytes.Buffer
err := th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err := th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000") channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000")
@@ -302,7 +298,7 @@ func TestExportGMChannel(t *testing.T) {
th1.CreateGroupChannel(user1, user2) th1.CreateGroupChannel(user1, user2)
var b bytes.Buffer var b bytes.Buffer
err := th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err := th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000") channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000")
@@ -334,7 +330,7 @@ func TestExportGMandDMChannels(t *testing.T) {
th1.CreateGroupChannel(user1, user2) th1.CreateGroupChannel(user1, user2)
var b bytes.Buffer var b bytes.Buffer
err := th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err := th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000") channels, nErr := th1.App.Srv().Store.Channel().GetAllDirectChannelsForExportAfter(1000, "00000000")
@@ -417,7 +413,7 @@ func TestExportDMandGMPost(t *testing.T) {
assert.Equal(t, 4, len(posts)) assert.Equal(t, 4, len(posts))
var b bytes.Buffer var b bytes.Buffer
err = th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err = th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
th1.TearDown() th1.TearDown()
@@ -492,7 +488,7 @@ func TestExportPostWithProps(t *testing.T) {
require.NotEmpty(t, posts[1].Props) require.NotEmpty(t, posts[1].Props)
var b bytes.Buffer var b bytes.Buffer
err = th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err = th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
th1.TearDown() th1.TearDown()
@@ -530,7 +526,7 @@ func TestExportDMPostWithSelf(t *testing.T) {
th1.CreatePost(dmChannel) th1.CreatePost(dmChannel)
var b bytes.Buffer var b bytes.Buffer
err := th1.App.BulkExport(&b, "somefile", "somePath", "someDir") err := th1.App.BulkExport(&b, "somePath", BulkExportOpts{})
require.Nil(t, err) require.Nil(t, err)
posts, nErr := th1.App.Srv().Store.Post().GetDirectPostParentsForExportAfter(1000, "0000000") posts, nErr := th1.App.Srv().Store.Post().GetDirectPostParentsForExportAfter(1000, "0000000")
@@ -557,3 +553,57 @@ func TestExportDMPostWithSelf(t *testing.T) {
assert.Equal(t, 1, len((*posts[0].ChannelMembers))) assert.Equal(t, 1, len((*posts[0].ChannelMembers)))
assert.Equal(t, th1.BasicUser.Username, (*posts[0].ChannelMembers)[0]) assert.Equal(t, th1.BasicUser.Username, (*posts[0].ChannelMembers)[0])
} }
func TestBulkExport(t *testing.T) {
th := Setup(t)
testsDir, _ := fileutils.FindDir("tests")
dir, err := ioutil.TempDir("", "import_test")
require.Nil(t, err)
defer os.RemoveAll(dir)
extractImportFile := func(filePath string) *os.File {
importFile, err2 := os.Open(filePath)
require.Nil(t, err2)
defer importFile.Close()
info, err2 := importFile.Stat()
require.Nil(t, err2)
paths, err2 := utils.UnzipToPath(importFile, info.Size(), dir)
require.Nil(t, err2)
require.NotEmpty(t, paths)
jsonFile, err2 := os.Open(filepath.Join(dir, "import.jsonl"))
require.Nil(t, err2)
return jsonFile
}
jsonFile := extractImportFile(filepath.Join(testsDir, "import_test.zip"))
defer jsonFile.Close()
err, _ = th.App.BulkImportWithPath(jsonFile, false, 1, dir)
require.Nil(t, err)
exportFile, err := os.Create(filepath.Join(dir, "export.zip"))
require.Nil(t, err)
defer exportFile.Close()
opts := BulkExportOpts{
IncludeAttachments: true,
CreateArchive: true,
}
err = th.App.BulkExport(exportFile, dir, opts)
require.Nil(t, err)
th.TearDown()
th = Setup(t)
defer th.TearDown()
jsonFile = extractImportFile(filepath.Join(dir, "export.zip"))
defer jsonFile.Close()
err, _ = th.App.BulkImportWithPath(jsonFile, false, 1, filepath.Join(dir, "data"))
require.Nil(t, err)
}

Просмотреть файл

@@ -9,7 +9,7 @@ import (
"crypto/sha1" "crypto/sha1"
"errors" "errors"
"fmt" "fmt"
"io" "io/ioutil"
"net/http" "net/http"
"os" "os"
"path" "path"
@@ -1117,10 +1117,15 @@ func (a *App) importAttachment(data *AttachmentImportData, post *model.Post, tea
if file == nil || err != nil { if file == nil || err != nil {
return nil, model.NewAppError("BulkImport", "app.import.attachment.bad_file.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest) return nil, model.NewAppError("BulkImport", "app.import.attachment.bad_file.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest)
} }
defer file.Close()
timestamp := utils.TimeFromMillis(post.CreateAt) timestamp := utils.TimeFromMillis(post.CreateAt)
buf := bytes.NewBuffer(nil)
_, _ = io.Copy(buf, file) fileData, err := ioutil.ReadAll(file)
if err != nil {
return nil, model.NewAppError("BulkImport", "app.import.attachment.read_file_data.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest)
}
// Go over existing files in the post and see if there already exists a file with the same name, size and hash. If so - skip it // Go over existing files in the post and see if there already exists a file with the same name, size and hash. If so - skip it
if post.Id != "" { if post.Id != "" {
oldFiles, err := a.GetFileInfosForPost(post.Id, true) oldFiles, err := a.GetFileInfosForPost(post.Id, true)
@@ -1128,11 +1133,11 @@ func (a *App) importAttachment(data *AttachmentImportData, post *model.Post, tea
return nil, model.NewAppError("BulkImport", "app.import.attachment.file_upload.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest) return nil, model.NewAppError("BulkImport", "app.import.attachment.file_upload.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest)
} }
for _, oldFile := range oldFiles { for _, oldFile := range oldFiles {
if oldFile.Name != path.Base(file.Name()) || oldFile.Size != int64(buf.Len()) { if oldFile.Name != path.Base(file.Name()) || oldFile.Size != int64(len(fileData)) {
continue continue
} }
// check md5 // check md5
newHash := sha1.Sum(buf.Bytes()) newHash := sha1.Sum(fileData)
oldFileData, err := a.GetFile(oldFile.Id) oldFileData, err := a.GetFile(oldFile.Id)
if err != nil { if err != nil {
return nil, model.NewAppError("BulkImport", "app.import.attachment.file_upload.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest) return nil, model.NewAppError("BulkImport", "app.import.attachment.file_upload.error", map[string]interface{}{"FilePath": *data.Path}, "", http.StatusBadRequest)
@@ -1145,15 +1150,19 @@ func (a *App) importAttachment(data *AttachmentImportData, post *model.Post, tea
} }
} }
} }
fileInfo, appErr := a.DoUploadFile(timestamp, teamID, post.ChannelId, post.UserId, file.Name(), buf.Bytes())
mlog.Info("Uploading file with name", mlog.String("file_name", file.Name()))
fileInfo, appErr := a.DoUploadFile(timestamp, teamID, post.ChannelId, post.UserId, file.Name(), fileData)
if appErr != nil { if appErr != nil {
mlog.Error("Failed to upload file:", mlog.Err(appErr)) mlog.Error("Failed to upload file:", mlog.Err(appErr))
return nil, appErr return nil, appErr
} }
a.HandleImages([]string{fileInfo.PreviewPath}, []string{fileInfo.ThumbnailPath}, [][]byte{buf.Bytes()}) if fileInfo.IsImage() {
a.HandleImages([]string{fileInfo.PreviewPath}, []string{fileInfo.ThumbnailPath}, [][]byte{fileData})
}
mlog.Info("Uploading file with name", mlog.String("file_name", file.Name()))
return fileInfo, nil return fileInfo, nil
} }

Просмотреть файл

@@ -878,7 +878,7 @@ func (a *OpenTracingAppLayer) BuildSamlMetadataObject(idpMetadata []byte) (*mode
return resultVar0, resultVar1 return resultVar0, resultVar1
} }
func (a *OpenTracingAppLayer) BulkExport(writer io.Writer, file string, pathToEmojiDir string, dirNameToExportEmoji string) *model.AppError { func (a *OpenTracingAppLayer) BulkExport(writer io.Writer, outPath string, opts app.BulkExportOpts) *model.AppError {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.BulkExport") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.BulkExport")
@@ -890,7 +890,7 @@ func (a *OpenTracingAppLayer) BulkExport(writer io.Writer, file string, pathToEm
}() }()
defer span.Finish() defer span.Finish()
resultVar0 := a.app.BulkExport(writer, file, pathToEmojiDir, dirNameToExportEmoji) resultVar0 := a.app.BulkExport(writer, outPath, opts)
if resultVar0 != nil { if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0)) span.LogFields(spanlog.Error(resultVar0))
@@ -2756,6 +2756,28 @@ func (a *OpenTracingAppLayer) DeleteEphemeralPost(userID string, postId string)
a.app.DeleteEphemeralPost(userID, postId) a.app.DeleteEphemeralPost(userID, postId)
} }
func (a *OpenTracingAppLayer) DeleteExport(name string) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteExport")
a.ctx = newCtx
a.app.Srv().Store.SetContext(newCtx)
defer func() {
a.app.Srv().Store.SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.DeleteExport(name)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) DeleteFlaggedPosts(postId string) { func (a *OpenTracingAppLayer) DeleteFlaggedPosts(postId string) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteFlaggedPosts") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteFlaggedPosts")
@@ -10423,6 +10445,28 @@ func (a *OpenTracingAppLayer) ListDirectory(path string) ([]string, *model.AppEr
return resultVar0, resultVar1 return resultVar0, resultVar1
} }
func (a *OpenTracingAppLayer) ListExports() ([]string, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListExports")
a.ctx = newCtx
a.app.Srv().Store.SetContext(newCtx)
defer func() {
a.app.Srv().Store.SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.ListExports()
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) ListImports() ([]string, *model.AppError) { func (a *OpenTracingAppLayer) ListImports() ([]string, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListImports") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListImports")

Просмотреть файл

@@ -7,13 +7,15 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"time" "time"
"github.com/mattermost/mattermost-server/v5/app"
"github.com/mattermost/mattermost-server/v5/audit"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/mattermost/mattermost-server/v5/audit"
"github.com/mattermost/mattermost-server/v5/model"
) )
var ExportCmd = &cobra.Command{ var ExportCmd = &cobra.Command{
@@ -74,6 +76,8 @@ func init() {
GlobalRelayZipExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.") GlobalRelayZipExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
BulkExportCmd.Flags().Bool("all-teams", true, "Export all teams from the server.") BulkExportCmd.Flags().Bool("all-teams", true, "Export all teams from the server.")
BulkExportCmd.Flags().Bool("attachments", false, "Also export file attachments.")
BulkExportCmd.Flags().Bool("archive", false, "Outputs a single archive file.")
ExportCmd.AddCommand(ScheduleExportCmd) ExportCmd.AddCommand(ScheduleExportCmd)
ExportCmd.AddCommand(CsvExportCmd) ExportCmd.AddCommand(CsvExportCmd)
@@ -202,25 +206,31 @@ func bulkExportCmdF(command *cobra.Command, args []string) error {
return errors.New("Nothing to export. Please specify the --all-teams flag to export all teams.") return errors.New("Nothing to export. Please specify the --all-teams flag to export all teams.")
} }
attachments, err := command.Flags().GetBool("attachments")
if err != nil {
return errors.Wrap(err, "attachments flag error")
}
archive, err := command.Flags().GetBool("archive")
if err != nil {
return errors.Wrap(err, "archive flag error")
}
fileWriter, err := os.Create(args[0]) fileWriter, err := os.Create(args[0])
if err != nil { if err != nil {
return err return err
} }
defer fileWriter.Close() defer fileWriter.Close()
// Path to directory of custom emoji outPath, err := filepath.Abs(args[0])
pathToEmojiDir := "data/emoji/" if err != nil {
return err
customDataDir := a.Config().FileSettings.Directory
if customDataDir != nil && *customDataDir != "" {
pathToEmojiDir = *customDataDir + "emoji/"
} }
// Name of the directory to export custom emoji var opts app.BulkExportOpts
dirNameToExportEmoji := "exported_emoji" opts.IncludeAttachments = attachments
opts.CreateArchive = archive
// args[0] points to the filename/filepath passed with export bulk command if err := a.BulkExport(fileWriter, filepath.Dir(outPath), opts); err != nil {
if err := a.BulkExport(fileWriter, args[0], pathToEmojiDir, dirNameToExportEmoji); err != nil {
CommandPrintErrorln(err.Error()) CommandPrintErrorln(err.Error())
return err return err
} }

Просмотреть файл

@@ -38,6 +38,7 @@ func init() {
BulkImportCmd.Flags().Bool("apply", false, "Save the import data to the database. Use with caution - this cannot be reverted.") BulkImportCmd.Flags().Bool("apply", false, "Save the import data to the database. Use with caution - this cannot be reverted.")
BulkImportCmd.Flags().Bool("validate", false, "Validate the import data without making any changes to the system.") BulkImportCmd.Flags().Bool("validate", false, "Validate the import data without making any changes to the system.")
BulkImportCmd.Flags().Int("workers", 2, "How many workers to run whilst doing the import.") BulkImportCmd.Flags().Int("workers", 2, "How many workers to run whilst doing the import.")
BulkImportCmd.Flags().String("import-path", "", "A path to the data directory to import files from.")
ImportCmd.AddCommand( ImportCmd.AddCommand(
BulkImportCmd, BulkImportCmd,
@@ -118,6 +119,11 @@ func bulkImportCmdF(command *cobra.Command, args []string) error {
return errors.New("Workers flag error") return errors.New("Workers flag error")
} }
importPath, err := command.Flags().GetString("import-path")
if err != nil {
return errors.New("import-path flag error")
}
if len(args) != 1 { if len(args) != 1 {
return errors.New("Incorrect number of arguments.") return errors.New("Incorrect number of arguments.")
} }
@@ -143,7 +149,7 @@ func bulkImportCmdF(command *cobra.Command, args []string) error {
CommandPrettyPrintln("") CommandPrettyPrintln("")
if err, lineNumber := a.BulkImport(fileReader, !apply, workers); err != nil { if err, lineNumber := a.BulkImportWithPath(fileReader, !apply, workers, importPath); err != nil {
CommandPrintErrorln(err.Error()) CommandPrintErrorln(err.Error())
if lineNumber != 0 { if lineNumber != 0 {
CommandPrintErrorln(fmt.Sprintf("Error occurred on data file line %v", lineNumber)) CommandPrintErrorln(fmt.Sprintf("Error occurred on data file line %v", lineNumber))

Просмотреть файл

@@ -1324,6 +1324,10 @@
"id": "api.emoji.upload.open.app_error", "id": "api.emoji.upload.open.app_error",
"translation": "Unable to create the emoji. An error occurred when trying to open the attached image." "translation": "Unable to create the emoji. An error occurred when trying to open the attached image."
}, },
{
"id": "api.export.export_not_found.app_error",
"translation": "Unable to find export file."
},
{ {
"id": "api.file.append_file.app_error", "id": "api.file.append_file.app_error",
"translation": "Unable to append data to the file." "translation": "Unable to append data to the file."
@@ -4086,6 +4090,22 @@
"id": "app.emoji.get_list.internal_error", "id": "app.emoji.get_list.internal_error",
"translation": "Unable to get the emoji." "translation": "Unable to get the emoji."
}, },
{
"id": "app.export.export_attachment.copy_file.error",
"translation": "Failed to copy file during export."
},
{
"id": "app.export.export_attachment.create_file.error",
"translation": "Failed to created file during export."
},
{
"id": "app.export.export_attachment.mkdirall.error",
"translation": "Failed to create directory during export."
},
{
"id": "app.export.export_attachment.zip_create_header.error",
"translation": "Failed to create zip header during export."
},
{ {
"id": "app.export.export_custom_emoji.copy_emoji_images.error", "id": "app.export.export_custom_emoji.copy_emoji_images.error",
"translation": "Unable to copy custom emoji images" "translation": "Unable to copy custom emoji images"
@@ -4098,6 +4118,14 @@
"id": "app.export.export_write_line.json_marshall.error", "id": "app.export.export_write_line.json_marshall.error",
"translation": "An error occurred marshalling the JSON data for export." "translation": "An error occurred marshalling the JSON data for export."
}, },
{
"id": "app.export.marshal.app_error",
"translation": "Unable to marshal response."
},
{
"id": "app.export.zip_create.error",
"translation": "Failed to add file to zip archive during export."
},
{ {
"id": "app.file_info.get.app_error", "id": "app.file_info.get.app_error",
"translation": "Unable to get the file info." "translation": "Unable to get the file info."
@@ -4146,6 +4174,10 @@
"id": "app.import.attachment.file_upload.error", "id": "app.import.attachment.file_upload.error",
"translation": "Error uploading the file: \"{{.FilePath}}\"" "translation": "Error uploading the file: \"{{.FilePath}}\""
}, },
{
"id": "app.import.attachment.read_file_data.error",
"translation": "Failed to read file attachment during import."
},
{ {
"id": "app.import.bulk_import.file_scan.error", "id": "app.import.bulk_import.file_scan.error",
"translation": "Error reading import data file." "translation": "Error reading import data file."
@@ -7274,6 +7306,14 @@
"id": "model.config.is_valid.encrypt_sql.app_error", "id": "model.config.is_valid.encrypt_sql.app_error",
"translation": "Invalid at rest encrypt key for SQL settings. Must be 32 chars or more." "translation": "Invalid at rest encrypt key for SQL settings. Must be 32 chars or more."
}, },
{
"id": "model.config.is_valid.export.directory.app_error",
"translation": "Value for Directory should not be empty."
},
{
"id": "model.config.is_valid.export.retention_days_too_low.app_error",
"translation": "Invalid value for RetentionDays. Value should be greater than 0"
},
{ {
"id": "model.config.is_valid.file_driver.app_error", "id": "model.config.is_valid.file_driver.app_error",
"translation": "Invalid driver name for file settings. Must be 'local' or 'amazons3'." "translation": "Invalid driver name for file settings. Must be 'local' or 'amazons3'."

Просмотреть файл

@@ -27,4 +27,7 @@ import (
// This is a placeholder so this package can be imported in Team Edition when it will be otherwise empty. // This is a placeholder so this package can be imported in Team Edition when it will be otherwise empty.
_ "github.com/mattermost/mattermost-server/v5/jobs/import_delete" _ "github.com/mattermost/mattermost-server/v5/jobs/import_delete"
// This is a placeholder so this package can be imported in Team Edition when it will be otherwise empty.
_ "github.com/mattermost/mattermost-server/v5/jobs/export_process"
) )

138
jobs/export_process/worker.go Обычный файл
Просмотреть файл

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package export_process
import (
"io"
"path/filepath"
"github.com/mattermost/mattermost-server/v5/app"
"github.com/mattermost/mattermost-server/v5/jobs"
tjobs "github.com/mattermost/mattermost-server/v5/jobs/interfaces"
"github.com/mattermost/mattermost-server/v5/mlog"
"github.com/mattermost/mattermost-server/v5/model"
)
func init() {
app.RegisterJobsExportProcessInterface(func(a *app.App) tjobs.ExportProcessInterface {
return &ExportProcessInterfaceImpl{a}
})
}
type ExportProcessInterfaceImpl struct {
app *app.App
}
type ExportProcessWorker struct {
name string
stopChan chan struct{}
stoppedChan chan struct{}
jobsChan chan model.Job
jobServer *jobs.JobServer
app *app.App
}
func (i *ExportProcessInterfaceImpl) MakeWorker() model.Worker {
return &ExportProcessWorker{
name: "ExportProcess",
stopChan: make(chan struct{}),
stoppedChan: make(chan struct{}),
jobsChan: make(chan model.Job),
jobServer: i.app.Srv().Jobs,
app: i.app,
}
}
func (w *ExportProcessWorker) JobChannel() chan<- model.Job {
return w.jobsChan
}
func (w *ExportProcessWorker) Run() {
mlog.Debug("Worker started", mlog.String("worker", w.name))
defer func() {
mlog.Debug("Worker finished", mlog.String("worker", w.name))
close(w.stoppedChan)
}()
for {
select {
case <-w.stopChan:
mlog.Debug("Worker received stop signal", mlog.String("worker", w.name))
return
case job := <-w.jobsChan:
mlog.Debug("Worker received a new candidate job.", mlog.String("worker", w.name))
w.doJob(&job)
}
}
}
func (w *ExportProcessWorker) Stop() {
mlog.Debug("Worker stopping", mlog.String("worker", w.name))
close(w.stopChan)
<-w.stoppedChan
}
func (w *ExportProcessWorker) doJob(job *model.Job) {
if claimed, err := w.jobServer.ClaimJob(job); err != nil {
mlog.Warn("Worker experienced an error while trying to claim job",
mlog.String("worker", w.name),
mlog.String("job_id", job.Id),
mlog.String("error", err.Error()))
return
} else if !claimed {
return
}
opts := app.BulkExportOpts{
CreateArchive: true,
}
includeAttachments, ok := job.Data["include_attachments"]
if ok && includeAttachments == "true" {
opts.IncludeAttachments = true
}
outPath := *w.app.Config().ExportSettings.Directory
exportFilename := model.NewId() + "_export.zip"
rd, wr := io.Pipe()
errCh := make(chan *model.AppError, 1)
go func() {
defer close(errCh)
_, appErr := w.app.WriteFile(rd, filepath.Join(outPath, exportFilename))
errCh <- appErr
}()
appErr := w.app.BulkExport(wr, outPath, opts)
if err := wr.Close(); err != nil {
mlog.Warn("Worker: error closing writer")
}
if appErr != nil {
w.setJobError(job, appErr)
return
}
if appErr := <-errCh; appErr != nil {
w.setJobError(job, appErr)
return
}
mlog.Info("Worker: Job is complete", mlog.String("worker", w.name), mlog.String("job_id", job.Id))
w.setJobSuccess(job)
}
func (w *ExportProcessWorker) setJobSuccess(job *model.Job) {
if err := w.app.Srv().Jobs.SetJobSuccess(job); err != nil {
mlog.Error("Worker: Failed to set success for job", mlog.String("worker", w.name), mlog.String("job_id", job.Id), mlog.String("error", err.Error()))
w.setJobError(job, err)
}
}
func (w *ExportProcessWorker) setJobError(job *model.Job, appError *model.AppError) {
if err := w.app.Srv().Jobs.SetJobError(job, appError); err != nil {
mlog.Error("Worker: Failed to set job error", mlog.String("worker", w.name), mlog.String("job_id", job.Id), mlog.String("error", err.Error()))
}
}

Просмотреть файл

@@ -165,7 +165,7 @@ func (w *ImportProcessWorker) doJob(job *model.Job) {
} }
// do the actual import. // do the actual import.
appErr, lineNumber := w.app.BulkImportWithPath(jsonFile, false, runtime.NumCPU(), dir) appErr, lineNumber := w.app.BulkImportWithPath(jsonFile, false, runtime.NumCPU(), filepath.Join(dir, app.ExportDataDir))
if appErr != nil { if appErr != nil {
job.Data["line_number"] = strconv.Itoa(lineNumber) job.Data["line_number"] = strconv.Itoa(lineNumber)
w.setJobError(job, appErr) w.setJobError(job, appErr)

12
jobs/interfaces/export_process_interface.go Обычный файл
Просмотреть файл

@@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package interfaces
import (
"github.com/mattermost/mattermost-server/v5/model"
)
type ExportProcessInterface interface {
MakeWorker() model.Worker
}

Просмотреть файл

@@ -163,6 +163,13 @@ func (watcher *Watcher) PollAndNotify() {
default: default:
} }
} }
} else if job.Type == model.JOB_TYPE_EXPORT_PROCESS {
if watcher.workers.ExportProcess != nil {
select {
case watcher.workers.ExportProcess.JobChannel() <- *job:
default:
}
}
} else if job.Type == model.JOB_TYPE_CLOUD { } else if job.Type == model.JOB_TYPE_CLOUD {
if watcher.workers.Cloud != nil { if watcher.workers.Cloud != nil {
select { select {

Просмотреть файл

@@ -32,6 +32,7 @@ type JobServer struct {
ActiveUsers tjobs.ActiveUsersJobInterface ActiveUsers tjobs.ActiveUsersJobInterface
ImportProcess tjobs.ImportProcessInterface ImportProcess tjobs.ImportProcessInterface
ImportDelete tjobs.ImportDeleteInterface ImportDelete tjobs.ImportDeleteInterface
ExportProcess tjobs.ExportProcessInterface
Cloud ejobs.CloudJobInterface Cloud ejobs.CloudJobInterface
} }

Просмотреть файл

@@ -29,6 +29,7 @@ type Workers struct {
ActiveUsers model.Worker ActiveUsers model.Worker
ImportProcess model.Worker ImportProcess model.Worker
ImportDelete model.Worker ImportDelete model.Worker
ExportProcess model.Worker
Cloud model.Worker Cloud model.Worker
listenerId string listenerId string
@@ -92,6 +93,10 @@ func (srv *JobServer) InitWorkers() *Workers {
workers.ImportDelete = importDeleteInterface.MakeWorker() workers.ImportDelete = importDeleteInterface.MakeWorker()
} }
if exportProcessInterface := srv.ExportProcess; exportProcessInterface != nil {
workers.ExportProcess = exportProcessInterface.MakeWorker()
}
if cloudInterface := srv.Cloud; cloudInterface != nil { if cloudInterface := srv.Cloud; cloudInterface != nil {
workers.Cloud = cloudInterface.MakeWorker() workers.Cloud = cloudInterface.MakeWorker()
} }
@@ -155,6 +160,10 @@ func (workers *Workers) Start() *Workers {
go workers.ImportDelete.Run() go workers.ImportDelete.Run()
} }
if workers.ExportProcess != nil {
go workers.ExportProcess.Run()
}
if workers.Cloud != nil { if workers.Cloud != nil {
go workers.Cloud.Run() go workers.Cloud.Run()
} }
@@ -276,6 +285,10 @@ func (workers *Workers) Stop() *Workers {
workers.ImportDelete.Stop() workers.ImportDelete.Stop()
} }
if workers.ExportProcess != nil {
workers.ExportProcess.Stop()
}
if workers.Cloud != nil { if workers.Cloud != nil {
workers.Cloud.Stop() workers.Cloud.Stop()
} }

Просмотреть файл

@@ -34,6 +34,7 @@ const (
HEADER_CLOUD_TOKEN = "X-Cloud-Token" HEADER_CLOUD_TOKEN = "X-Cloud-Token"
HEADER_REQUESTED_WITH = "X-Requested-With" HEADER_REQUESTED_WITH = "X-Requested-With"
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest" HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
HEADER_RANGE = "Range"
STATUS = "status" STATUS = "status"
STATUS_OK = "OK" STATUS_OK = "OK"
STATUS_FAIL = "FAIL" STATUS_FAIL = "FAIL"
@@ -550,6 +551,14 @@ func (c *Client4) GetImportsRoute() string {
return "/imports" return "/imports"
} }
func (c *Client4) GetExportsRoute() string {
return "/exports"
}
func (c *Client4) GetExportRoute(name string) string {
return fmt.Sprintf(c.GetExportsRoute()+"/%v", name)
}
func (c *Client4) DoApiGet(url string, etag string) (*http.Response, *AppError) { func (c *Client4) DoApiGet(url string, etag string) (*http.Response, *AppError) {
return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag) return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag)
} }
@@ -575,21 +584,25 @@ func (c *Client4) DoApiDelete(url string) (*http.Response, *AppError) {
} }
func (c *Client4) DoApiRequest(method, url, data, etag string) (*http.Response, *AppError) { func (c *Client4) DoApiRequest(method, url, data, etag string) (*http.Response, *AppError) {
return c.doApiRequestReader(method, url, strings.NewReader(data), etag) return c.doApiRequestReader(method, url, strings.NewReader(data), map[string]string{HEADER_ETAG_CLIENT: etag})
}
func (c *Client4) DoApiRequestWithHeaders(method, url, data string, headers map[string]string) (*http.Response, *AppError) {
return c.doApiRequestReader(method, url, strings.NewReader(data), headers)
} }
func (c *Client4) doApiRequestBytes(method, url string, data []byte, etag string) (*http.Response, *AppError) { func (c *Client4) doApiRequestBytes(method, url string, data []byte, etag string) (*http.Response, *AppError) {
return c.doApiRequestReader(method, url, bytes.NewReader(data), etag) return c.doApiRequestReader(method, url, bytes.NewReader(data), map[string]string{HEADER_ETAG_CLIENT: etag})
} }
func (c *Client4) doApiRequestReader(method, url string, data io.Reader, etag string) (*http.Response, *AppError) { func (c *Client4) doApiRequestReader(method, url string, data io.Reader, headers map[string]string) (*http.Response, *AppError) {
rq, err := http.NewRequest(method, url, data) rq, err := http.NewRequest(method, url, data)
if err != nil { if err != nil {
return nil, NewAppError(url, "model.client.connecting.app_error", nil, err.Error(), http.StatusBadRequest) return nil, NewAppError(url, "model.client.connecting.app_error", nil, err.Error(), http.StatusBadRequest)
} }
if etag != "" { for k, v := range headers {
rq.Header.Set(HEADER_ETAG_CLIENT, etag) rq.Header.Set(k, v)
} }
if c.AuthToken != "" { if c.AuthToken != "" {
@@ -5668,7 +5681,7 @@ func (c *Client4) GetUploadsForUser(userId string) ([]*UploadSession, *Response)
// a FileInfo object. // a FileInfo object.
func (c *Client4) UploadData(uploadId string, data io.Reader) (*FileInfo, *Response) { func (c *Client4) UploadData(uploadId string, data io.Reader) (*FileInfo, *Response) {
url := c.GetUploadRoute(uploadId) url := c.GetUploadRoute(uploadId)
r, err := c.doApiRequestReader("POST", c.ApiUrl+url, data, "") r, err := c.doApiRequestReader("POST", c.ApiUrl+url, data, nil)
if err != nil { if err != nil {
return nil, BuildErrorResponse(r, err) return nil, BuildErrorResponse(r, err)
} }
@@ -5816,6 +5829,43 @@ func (c *Client4) ListImports() ([]string, *Response) {
return ArrayFromJson(r.Body), BuildResponse(r) return ArrayFromJson(r.Body), BuildResponse(r)
} }
func (c *Client4) ListExports() ([]string, *Response) {
r, err := c.DoApiGet(c.GetExportsRoute(), "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return ArrayFromJson(r.Body), BuildResponse(r)
}
func (c *Client4) DeleteExport(name string) (bool, *Response) {
r, err := c.DoApiDelete(c.GetExportRoute(name))
if err != nil {
return false, BuildErrorResponse(r, err)
}
defer closeBody(r)
return CheckStatusOK(r), BuildResponse(r)
}
func (c *Client4) DownloadExport(name string, wr io.Writer, offset int64) (int64, *Response) {
var headers map[string]string
if offset > 0 {
headers = map[string]string{
HEADER_RANGE: fmt.Sprintf("bytes=%d-", offset),
}
}
r, appErr := c.DoApiRequestWithHeaders(http.MethodGet, c.ApiUrl+c.GetExportRoute(name), "", headers)
if appErr != nil {
return 0, BuildErrorResponse(r, appErr)
}
defer closeBody(r)
n, err := io.Copy(wr, r.Body)
if err != nil {
return n, BuildErrorResponse(r, NewAppError("DownloadExport", "model.client.copy.app_error", nil, err.Error(), r.StatusCode))
}
return n, BuildResponse(r)
}
func (c *Client4) GetThreadMentionsForUserPerChannel(userId, teamId string) (map[string]int64, *Response) { func (c *Client4) GetThreadMentionsForUserPerChannel(userId, teamId string) (map[string]int64, *Response) {
url := c.GetUserThreadsRoute(userId, teamId) url := c.GetUserThreadsRoute(userId, teamId)
r, appErr := c.DoApiGet(url+"/mention_counts", "") r, appErr := c.DoApiGet(url+"/mention_counts", "")

Просмотреть файл

@@ -122,6 +122,9 @@ const (
IMPORT_SETTINGS_DEFAULT_DIRECTORY = "./import" IMPORT_SETTINGS_DEFAULT_DIRECTORY = "./import"
IMPORT_SETTINGS_DEFAULT_RETENTION_DAYS = 30 IMPORT_SETTINGS_DEFAULT_RETENTION_DAYS = 30
EXPORT_SETTINGS_DEFAULT_DIRECTORY = "./export"
EXPORT_SETTINGS_DEFAULT_RETENTION_DAYS = 30
EMAIL_SETTINGS_DEFAULT_FEEDBACK_ORGANIZATION = "" EMAIL_SETTINGS_DEFAULT_FEEDBACK_ORGANIZATION = ""
SUPPORT_SETTINGS_DEFAULT_TERMS_OF_SERVICE_LINK = "https://about.mattermost.com/default-terms/" SUPPORT_SETTINGS_DEFAULT_TERMS_OF_SERVICE_LINK = "https://about.mattermost.com/default-terms/"
@@ -2936,6 +2939,37 @@ func (s *ImportSettings) SetDefaults() {
} }
} }
// ExportSettings defines configuration settings for file exports.
type ExportSettings struct {
// The directory where to store the exported files.
Directory *string
// The number of days to retain the exported files before deleting them.
RetentionDays *int
}
func (s *ExportSettings) isValid() *AppError {
if *s.Directory == "" {
return NewAppError("Config.IsValid", "model.config.is_valid.export.directory.app_error", nil, "", http.StatusBadRequest)
}
if *s.RetentionDays <= 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.export.retention_days_too_low.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
// SetDefaults applies the default settings to the struct.
func (s *ExportSettings) SetDefaults() {
if s.Directory == nil || *s.Directory == "" {
s.Directory = NewString(EXPORT_SETTINGS_DEFAULT_DIRECTORY)
}
if s.RetentionDays == nil {
s.RetentionDays = NewInt(EXPORT_SETTINGS_DEFAULT_RETENTION_DAYS)
}
}
type ConfigFunc func() *Config type ConfigFunc func() *Config
const ConfigAccessTagType = "access" const ConfigAccessTagType = "access"
@@ -3013,6 +3047,7 @@ type Config struct {
CloudSettings CloudSettings CloudSettings CloudSettings
FeatureFlags *FeatureFlags `json:",omitempty"` FeatureFlags *FeatureFlags `json:",omitempty"`
ImportSettings ImportSettings ImportSettings ImportSettings
ExportSettings ExportSettings
} }
func (o *Config) Clone() *Config { func (o *Config) Clone() *Config {
@@ -3121,6 +3156,7 @@ func (o *Config) SetDefaults() {
o.FeatureFlags.SetDefaults() o.FeatureFlags.SetDefaults()
} }
o.ImportSettings.SetDefaults() o.ImportSettings.SetDefaults()
o.ExportSettings.SetDefaults()
} }
func (o *Config) IsValid() *AppError { func (o *Config) IsValid() *AppError {

Просмотреть файл

@@ -1439,3 +1439,31 @@ func TestConfigImportSettingsIsValid(t *testing.T) {
require.NotNil(t, err) require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.import.retention_days_too_low.app_error", err.Id) require.Equal(t, "model.config.is_valid.import.retention_days_too_low.app_error", err.Id)
} }
func TestConfigExportSettingsDefaults(t *testing.T) {
cfg := Config{}
cfg.SetDefaults()
require.Equal(t, "./export", *cfg.ExportSettings.Directory)
require.Equal(t, 30, *cfg.ExportSettings.RetentionDays)
}
func TestConfigExportSettingsIsValid(t *testing.T) {
cfg := Config{}
cfg.SetDefaults()
err := cfg.ExportSettings.isValid()
require.Nil(t, err)
*cfg.ExportSettings.Directory = ""
err = cfg.ExportSettings.isValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.export.directory.app_error", err.Id)
cfg.SetDefaults()
*cfg.ExportSettings.RetentionDays = 0
err = cfg.ExportSettings.isValid()
require.NotNil(t, err)
require.Equal(t, "model.config.is_valid.export.retention_days_too_low.app_error", err.Id)
}

Просмотреть файл

@@ -24,6 +24,7 @@ const (
JOB_TYPE_ACTIVE_USERS = "active_users" JOB_TYPE_ACTIVE_USERS = "active_users"
JOB_TYPE_IMPORT_PROCESS = "import_process" JOB_TYPE_IMPORT_PROCESS = "import_process"
JOB_TYPE_IMPORT_DELETE = "import_delete" JOB_TYPE_IMPORT_DELETE = "import_delete"
JOB_TYPE_EXPORT_PROCESS = "export_process"
JOB_TYPE_CLOUD = "cloud" JOB_TYPE_CLOUD = "cloud"
JOB_STATUS_PENDING = "pending" JOB_STATUS_PENDING = "pending"
@@ -70,6 +71,7 @@ func (j *Job) IsValid() *AppError {
case JOB_TYPE_ACTIVE_USERS: case JOB_TYPE_ACTIVE_USERS:
case JOB_TYPE_IMPORT_PROCESS: case JOB_TYPE_IMPORT_PROCESS:
case JOB_TYPE_IMPORT_DELETE: case JOB_TYPE_IMPORT_DELETE:
case JOB_TYPE_EXPORT_PROCESS:
case JOB_TYPE_CLOUD: case JOB_TYPE_CLOUD:
default: default:
return NewAppError("Job.IsValid", "model.job.is_valid.type.app_error", nil, "id="+j.Id, http.StatusBadRequest) return NewAppError("Job.IsValid", "model.job.is_valid.type.app_error", nil, "id="+j.Id, http.StatusBadRequest)

Просмотреть файл

@@ -38,12 +38,16 @@ func UnzipToPath(zipFile io.ReaderAt, size int64, outPath string) ([]string, err
path := filepath.Join(outPath, filePath) path := filepath.Join(outPath, filePath)
paths[i] = path paths[i] = path
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
if err := os.Mkdir(path, 0744); err != nil { if err := os.Mkdir(path, 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err) return nil, fmt.Errorf("failed to create directory: %w", err)
} }
continue continue
} }
if _, err := os.Stat(filepath.Dir(path)); os.IsNotExist(err) {
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
}
outFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600) outFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err) return nil, fmt.Errorf("failed to create file: %w", err)

Просмотреть файл

@@ -83,6 +83,7 @@ type Params struct {
FilterParentTeamPermitted bool FilterParentTeamPermitted bool
CategoryId string CategoryId string
WarnMetricId string WarnMetricId string
ExportName string
// Cloud // Cloud
InvoiceId string InvoiceId string
@@ -346,5 +347,9 @@ func ParamsFromRequest(r *http.Request) *Params {
params.WarnMetricId = val params.WarnMetricId = val
} }
if val, ok := props["export_name"]; ok {
params.ExportName = val
}
return params return params
} }