[MM-63556] mmctl: Add compliance export download cmd (#30576)
* add mmctl compliance export download command and tests - Introduced `ComplianceExportDownloadCmd` to facilitate downloading compliance export files. - Implemented the `DownloadComplianceExport` method in the Client interface for handling file downloads. - Added unit tests for the download command, covering successful downloads, error handling for non-existent jobs, and retries on failure. - Included end-to-end tests to validate the command's functionality. - Updated documentation to include usage examples and options for the new command. * don't know why this was left out * PR comments * adjust test for new retry logic * refactored download fn for compliance_export and export * fix test due to fixed logic * docs
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
60a747f975
Коммит
b33a7e362f
@@ -9,6 +9,7 @@ func (api *API) InitJobLocal() {
|
|||||||
api.BaseRoutes.Jobs.Handle("", api.APILocal(getJobs)).Methods(http.MethodGet)
|
api.BaseRoutes.Jobs.Handle("", api.APILocal(getJobs)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.Jobs.Handle("", api.APILocal(createJob)).Methods(http.MethodPost)
|
api.BaseRoutes.Jobs.Handle("", api.APILocal(createJob)).Methods(http.MethodPost)
|
||||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APILocal(getJob)).Methods(http.MethodGet)
|
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APILocal(getJob)).Methods(http.MethodGet)
|
||||||
|
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APILocal(downloadJob)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APILocal(cancelJob)).Methods(http.MethodPost)
|
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APILocal(cancelJob)).Methods(http.MethodPost)
|
||||||
api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APILocal(getJobsByType)).Methods(http.MethodGet)
|
api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APILocal(getJobsByType)).Methods(http.MethodGet)
|
||||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APILocal(updateJobStatus)).Methods(http.MethodPatch)
|
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APILocal(updateJobStatus)).Methods(http.MethodPatch)
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ type Client interface {
|
|||||||
ListExports(ctx context.Context) ([]string, *model.Response, error)
|
ListExports(ctx context.Context) ([]string, *model.Response, error)
|
||||||
DeleteExport(ctx context.Context, name string) (*model.Response, error)
|
DeleteExport(ctx context.Context, name string) (*model.Response, error)
|
||||||
DownloadExport(ctx context.Context, name string, wr io.Writer, offset int64) (int64, *model.Response, error)
|
DownloadExport(ctx context.Context, name string, wr io.Writer, offset int64) (int64, *model.Response, error)
|
||||||
|
DownloadComplianceExport(ctx context.Context, jobID string, wr io.Writer) (string, error)
|
||||||
GeneratePresignedURL(ctx context.Context, name string) (*model.PresignURLResponse, *model.Response, error)
|
GeneratePresignedURL(ctx context.Context, name string) (*model.PresignURLResponse, *model.Response, error)
|
||||||
ResetSamlAuthDataToEmail(ctx context.Context, includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error)
|
ResetSamlAuthDataToEmail(ctx context.Context, includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error)
|
||||||
GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *model.Response, error)
|
GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *model.Response, error)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ package commands
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
||||||
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,15 +42,26 @@ var ComplianceExportCancelCmd = &cobra.Command{
|
|||||||
RunE: withClient(complianceExportCancelCmdF),
|
RunE: withClient(complianceExportCancelCmdF),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ComplianceExportDownloadCmd = &cobra.Command{
|
||||||
|
Use: "download [complianceExportJobID] [output filepath (optional)]",
|
||||||
|
Example: " compliance_export download o98rj3ur83dp5dppfyk5yk6osy",
|
||||||
|
Short: "Download compliance export file",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: withClient(complianceExportDownloadCmdF),
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
ComplianceExportListCmd.Flags().Int("page", 0, "Page number to fetch for the list of compliance export jobs")
|
ComplianceExportListCmd.Flags().Int("page", 0, "Page number to fetch for the list of compliance export jobs")
|
||||||
ComplianceExportListCmd.Flags().Int("per-page", DefaultPageSize, "Number of compliance export jobs to be fetched")
|
ComplianceExportListCmd.Flags().Int("per-page", DefaultPageSize, "Number of compliance export jobs to be fetched")
|
||||||
ComplianceExportListCmd.Flags().Bool("all", false, "Fetch all compliance export jobs. --page flag will be ignored if provided")
|
ComplianceExportListCmd.Flags().Bool("all", false, "Fetch all compliance export jobs. --page flag will be ignored if provided")
|
||||||
|
|
||||||
|
ComplianceExportDownloadCmd.Flags().Int("num-retries", 5, "Number of retries if the download fails")
|
||||||
|
|
||||||
ComplianceExportCmd.AddCommand(
|
ComplianceExportCmd.AddCommand(
|
||||||
ComplianceExportListCmd,
|
ComplianceExportListCmd,
|
||||||
ComplianceExportShowCmd,
|
ComplianceExportShowCmd,
|
||||||
ComplianceExportCancelCmd,
|
ComplianceExportCancelCmd,
|
||||||
|
ComplianceExportDownloadCmd,
|
||||||
)
|
)
|
||||||
RootCmd.AddCommand(ComplianceExportCmd)
|
RootCmd.AddCommand(ComplianceExportCmd)
|
||||||
}
|
}
|
||||||
@@ -75,3 +88,41 @@ func complianceExportCancelCmdF(c client.Client, command *cobra.Command, args []
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func complianceExportDownloadCmdF(c client.Client, command *cobra.Command, args []string) error {
|
||||||
|
jobID := args[0]
|
||||||
|
var path string
|
||||||
|
if len(args) > 1 {
|
||||||
|
path = args[1]
|
||||||
|
} else {
|
||||||
|
path = jobID + ".zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
retries, _ := command.Flags().GetInt("num-retries")
|
||||||
|
|
||||||
|
downloadFn := func(outFile *os.File) (string, error) {
|
||||||
|
return c.DownloadComplianceExport(context.TODO(), jobID, outFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestedFilename, err := downloadFile(path, downloadFn, retries, "compliance export")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't provide a path and got a suggested filename, rename the file
|
||||||
|
if len(args) == 1 && suggestedFilename != "" && suggestedFilename != path {
|
||||||
|
// If the suggested name already exists, don't overwrite
|
||||||
|
if _, err := os.Stat(suggestedFilename); err == nil {
|
||||||
|
printer.PrintWarning(fmt.Sprintf("File with the server's suggested name %q already exists, keeping %q", suggestedFilename, path))
|
||||||
|
} else {
|
||||||
|
if err := os.Rename(path, suggestedFilename); err != nil {
|
||||||
|
printer.PrintWarning(fmt.Sprintf("Could not rename file to the server's suggested name %q: %v", suggestedFilename, err))
|
||||||
|
} else {
|
||||||
|
path = suggestedFilename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printer.Print(fmt.Sprintf("Compliance export file downloaded to %q", path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,19 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/v8"
|
||||||
st "github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
st "github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
||||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *MmctlE2ETestSuite) TestComplianceExportListCmdE2E() {
|
func (s *MmctlE2ETestSuite) TestComplianceExportListCmdE2E() {
|
||||||
@@ -301,3 +308,280 @@ func (s *MmctlE2ETestSuite) TestComplianceExportCancelCmdE2E() {
|
|||||||
s.Require().Empty(printer.GetErrorLines())
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MmctlE2ETestSuite) TestComplianceExportDownloadCmdE2E() {
|
||||||
|
s.SetupMessageExportTestHelper()
|
||||||
|
|
||||||
|
s.Run("no permissions", func() {
|
||||||
|
printer.Clean()
|
||||||
|
|
||||||
|
now := model.GetMillis()
|
||||||
|
// Create a job
|
||||||
|
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||||
|
Id: st.NewTestID(),
|
||||||
|
CreateAt: now - 1000,
|
||||||
|
Status: model.JobStatusSuccess,
|
||||||
|
Type: model.JobTypeMessageExport,
|
||||||
|
StartAt: now - 1000,
|
||||||
|
LastActivityAt: now - 1000,
|
||||||
|
})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer func() {
|
||||||
|
// Ensure job is deleted from the database
|
||||||
|
var result string
|
||||||
|
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||||
|
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 0, "")
|
||||||
|
err = complianceExportDownloadCmdF(s.th.Client, cmd, []string{job.Id})
|
||||||
|
s.Require().EqualError(err, "failed to download compliance export file: You do not have the appropriate permissions.")
|
||||||
|
s.Require().Empty(printer.GetLines())
|
||||||
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RunForSystemAdminAndLocal("Download non-existent job", func(c client.Client) {
|
||||||
|
printer.Clean()
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 0, "")
|
||||||
|
err := complianceExportDownloadCmdF(c, cmd, []string{"non-existent-job-id"})
|
||||||
|
s.Require().EqualError(err, "failed to download compliance export file: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/jobs/non-existent-job-id/download'. Typo? are you missing a team_id or user_id as part of the url?")
|
||||||
|
s.Require().Empty(printer.GetLines())
|
||||||
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RunForSystemAdminAndLocal("existing, non empty compliance export", func(c client.Client) {
|
||||||
|
printer.Clean()
|
||||||
|
|
||||||
|
importFilePath := filepath.Join(server.GetPackagePath(), "test.zip")
|
||||||
|
f, err := os.Create(importFilePath)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_, err = f.WriteString("test data")
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = os.Remove(importFilePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().Int("num-retries", 0, "")
|
||||||
|
|
||||||
|
err = complianceExportDownloadCmdF(c, cmd, []string{"jobId", importFilePath})
|
||||||
|
s.Require().EqualError(err, "compliance export file already exists")
|
||||||
|
s.Require().Empty(printer.GetLines())
|
||||||
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
|
||||||
|
printer.Clean()
|
||||||
|
|
||||||
|
downloadPath := "explicit_path.zip"
|
||||||
|
defer os.Remove(downloadPath)
|
||||||
|
|
||||||
|
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
|
||||||
|
exportDir := "job_test_export"
|
||||||
|
// Create a compliance export with two zip files
|
||||||
|
exportFilePath := filepath.Join(serverDataDir, exportDir)
|
||||||
|
err = os.Mkdir(exportFilePath, 0755)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(exportFilePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create first zip file
|
||||||
|
zipPath1 := exportFilePath + "/export1.zip"
|
||||||
|
f1, err := os.Create(zipPath1)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_, err = f1.WriteString("test data 1")
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_ = f1.Close()
|
||||||
|
|
||||||
|
// Create second zip file
|
||||||
|
zipPath2 := exportFilePath + "/export2.zip"
|
||||||
|
f2, err := os.Create(zipPath2)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_, err = f2.WriteString("test data 2")
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_ = f2.Close()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(exportFilePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
now := model.GetMillis()
|
||||||
|
// Create a job
|
||||||
|
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||||
|
Id: st.NewTestID(),
|
||||||
|
CreateAt: now - 1000,
|
||||||
|
Status: model.JobStatusSuccess,
|
||||||
|
Type: model.JobTypeMessageExport,
|
||||||
|
StartAt: now - 1000,
|
||||||
|
LastActivityAt: now - 1000,
|
||||||
|
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
|
||||||
|
})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer func() {
|
||||||
|
// Ensure job is deleted from the database
|
||||||
|
var result string
|
||||||
|
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||||
|
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 0, "")
|
||||||
|
|
||||||
|
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id, downloadPath})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", downloadPath))
|
||||||
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
|
||||||
|
// Verify the file was downloaded
|
||||||
|
_, err = os.Stat(downloadPath)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
defer os.Remove(downloadPath)
|
||||||
|
|
||||||
|
// Verify the file is a zip with a directory with two zip files
|
||||||
|
zipReader, err := zip.OpenReader(downloadPath)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer zipReader.Close()
|
||||||
|
|
||||||
|
// Check that we have the expected files in the zip
|
||||||
|
foundExport1 := false
|
||||||
|
foundExport2 := false
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.Name == "export1.zip" {
|
||||||
|
foundExport1 = true
|
||||||
|
// Verify contents of export1.zip
|
||||||
|
rc, err := file.Open()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal("test data 1", string(content))
|
||||||
|
rc.Close()
|
||||||
|
} else if file.Name == "export2.zip" {
|
||||||
|
foundExport2 = true
|
||||||
|
// Verify contents of export2.zip
|
||||||
|
rc, err := file.Open()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal("test data 2", string(content))
|
||||||
|
rc.Close()
|
||||||
|
} else {
|
||||||
|
s.Failf("unexpected file found in downloaded zip", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
|
||||||
|
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
|
||||||
|
printer.Clean()
|
||||||
|
|
||||||
|
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
|
||||||
|
exportDir := "job_test_export"
|
||||||
|
expectedDownloadPath := exportDir + ".zip"
|
||||||
|
// Create a compliance export with two zip files
|
||||||
|
exportFilePath := filepath.Join(serverDataDir, exportDir)
|
||||||
|
err = os.Mkdir(exportFilePath, 0755)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(exportFilePath)
|
||||||
|
|
||||||
|
// also remove the downloaded file
|
||||||
|
defer os.Remove(expectedDownloadPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create first zip file
|
||||||
|
zipPath1 := exportFilePath + "/export1.zip"
|
||||||
|
f1, err := os.Create(zipPath1)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_, err = f1.WriteString("test data 1")
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_ = f1.Close()
|
||||||
|
|
||||||
|
// Create second zip file
|
||||||
|
zipPath2 := exportFilePath + "/export2.zip"
|
||||||
|
f2, err := os.Create(zipPath2)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_, err = f2.WriteString("test data 2")
|
||||||
|
s.Require().Nil(err)
|
||||||
|
_ = f2.Close()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(exportFilePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
now := model.GetMillis()
|
||||||
|
// Create a job
|
||||||
|
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||||
|
Id: st.NewTestID(),
|
||||||
|
CreateAt: now - 1000,
|
||||||
|
Status: model.JobStatusSuccess,
|
||||||
|
Type: model.JobTypeMessageExport,
|
||||||
|
StartAt: now - 1000,
|
||||||
|
LastActivityAt: now - 1000,
|
||||||
|
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
|
||||||
|
})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer func() {
|
||||||
|
// Ensure job is deleted from the database
|
||||||
|
var result string
|
||||||
|
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||||
|
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 0, "")
|
||||||
|
|
||||||
|
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", expectedDownloadPath))
|
||||||
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
|
||||||
|
// Verify the file was downloaded
|
||||||
|
_, err = os.Stat(expectedDownloadPath)
|
||||||
|
s.Require().Nil(err)
|
||||||
|
|
||||||
|
// Verify the file is a zip with a directory with two zip files
|
||||||
|
zipReader, err := zip.OpenReader(expectedDownloadPath)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer zipReader.Close()
|
||||||
|
|
||||||
|
// Check that we have the expected files in the zip
|
||||||
|
foundExport1 := false
|
||||||
|
foundExport2 := false
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.Name == "export1.zip" {
|
||||||
|
foundExport1 = true
|
||||||
|
// Verify contents of export1.zip
|
||||||
|
rc, err := file.Open()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal("test data 1", string(content))
|
||||||
|
rc.Close()
|
||||||
|
} else if file.Name == "export2.zip" {
|
||||||
|
foundExport2 = true
|
||||||
|
// Verify contents of export2.zip
|
||||||
|
rc, err := file.Open()
|
||||||
|
s.Require().NoError(err)
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal("test data 2", string(content))
|
||||||
|
rc.Close()
|
||||||
|
} else {
|
||||||
|
s.Failf("unexpected file found in downloaded zip", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
|
||||||
|
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
gomock "github.com/golang/mock/gomock"
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -224,6 +227,77 @@ func (s *MmctlUnitTestSuite) TestComplianceExportCancelCmdF() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MmctlUnitTestSuite) TestComplianceExportDownloadCmdF() {
|
||||||
|
mockJob := &model.Job{
|
||||||
|
Id: model.NewId(),
|
||||||
|
CreateAt: model.GetMillis(),
|
||||||
|
Type: model.JobTypeMessageExport,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Run("download job file successfully", func() {
|
||||||
|
printer.Clean()
|
||||||
|
defer func() {
|
||||||
|
_ = os.Remove("suggested-filename.zip")
|
||||||
|
}()
|
||||||
|
|
||||||
|
s.client.
|
||||||
|
EXPECT().
|
||||||
|
DownloadComplianceExport(gomock.Any(), mockJob.Id, gomock.Any()).
|
||||||
|
Return("suggested-filename.zip", nil).
|
||||||
|
Times(1)
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 5, "")
|
||||||
|
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
|
||||||
|
s.Require().Nil(err)
|
||||||
|
s.Len(printer.GetLines(), 1)
|
||||||
|
s.Len(printer.GetErrorLines(), 0)
|
||||||
|
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "suggested-filename.zip"), printer.GetLines()[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("download job file with explicit path", func() {
|
||||||
|
printer.Clean()
|
||||||
|
defer func() {
|
||||||
|
_ = os.Remove("custom-path.zip")
|
||||||
|
}()
|
||||||
|
|
||||||
|
s.client.
|
||||||
|
EXPECT().
|
||||||
|
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
|
||||||
|
Return("", nil).
|
||||||
|
Times(1)
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 5, "")
|
||||||
|
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id, "custom-path.zip"})
|
||||||
|
s.Require().Nil(err)
|
||||||
|
s.Len(printer.GetLines(), 1)
|
||||||
|
s.Len(printer.GetErrorLines(), 0)
|
||||||
|
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "custom-path.zip"), printer.GetLines()[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("download job with error", func() {
|
||||||
|
printer.Clean()
|
||||||
|
mockError := &model.AppError{
|
||||||
|
Message: "failed to download file",
|
||||||
|
}
|
||||||
|
|
||||||
|
s.client.
|
||||||
|
EXPECT().
|
||||||
|
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
|
||||||
|
Return("", mockError).
|
||||||
|
Times(6) // Initial attempt + 5 retries
|
||||||
|
|
||||||
|
cmd := makeCmd()
|
||||||
|
cmd.Flags().Int("num-retries", 5, "")
|
||||||
|
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
|
||||||
|
s.Require().NotNil(err)
|
||||||
|
s.EqualError(err, "failed to download compliance export after 5 retries: failed to download file")
|
||||||
|
s.Len(printer.GetLines(), 0)
|
||||||
|
s.Len(printer.GetErrorLines(), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func makeCmd() *cobra.Command {
|
func makeCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
cmd.Flags().Int("page", 0, "")
|
cmd.Flags().Int("page", 0, "")
|
||||||
|
|||||||
@@ -225,15 +225,36 @@ func exportDownloadCmdF(c client.Client, command *cobra.Command, args []string)
|
|||||||
|
|
||||||
retries, _ := command.Flags().GetInt("num-retries")
|
retries, _ := command.Flags().GetInt("num-retries")
|
||||||
|
|
||||||
|
downloadFn := func(outFile *os.File) (string, error) {
|
||||||
|
off, err := outFile.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to seek file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.DownloadExport(context.TODO(), name, outFile, off)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := downloadFile(path, downloadFn, retries, "export")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printer.Print(fmt.Sprintf("Export file downloaded to %q", path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile handles the common logic for downloading files in export and compliance_export commands
|
||||||
|
func downloadFile(path string, downloadFn func(*os.File) (string, error), retries int, fileType string) (string, error) {
|
||||||
var outFile *os.File
|
var outFile *os.File
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
switch {
|
switch {
|
||||||
case err != nil && !os.IsNotExist(err):
|
case err != nil && !os.IsNotExist(err):
|
||||||
// some error occurred and not because file doesn't exist
|
// some error occurred and not because file doesn't exist
|
||||||
return fmt.Errorf("failed to stat export file: %w", err)
|
return "", fmt.Errorf("failed to stat %s file: %w", fileType, err)
|
||||||
case err == nil && info.Size() > 0:
|
case err == nil && info.Size() > 0:
|
||||||
// we exit to avoid overwriting an existing non-empty file
|
// we exit to avoid overwriting an existing non-empty file
|
||||||
return fmt.Errorf("export file already exists")
|
return "", fmt.Errorf("%s file already exists", fileType)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
// file does not exist, we create it
|
// file does not exist, we create it
|
||||||
outFile, err = os.Create(path)
|
outFile, err = os.Create(path)
|
||||||
@@ -243,30 +264,24 @@ func exportDownloadCmdF(c client.Client, command *cobra.Command, args []string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create/open export file: %w", err)
|
return "", fmt.Errorf("failed to create/open %s file: %w", fileType, err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer outFile.Close()
|
||||||
|
|
||||||
i := 0
|
var suggestedFilename string
|
||||||
for i < retries+1 {
|
for i := range retries + 1 { // need to include the first attempt
|
||||||
off, err := outFile.Seek(0, io.SeekEnd)
|
suggestedFilename, err = downloadFn(outFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to seek export file: %w", err)
|
if i >= retries {
|
||||||
}
|
return "", fmt.Errorf("failed to download %s after %d retries: %w", fileType, retries, err)
|
||||||
|
}
|
||||||
if _, _, err := c.DownloadExport(context.TODO(), name, outFile, off); err != nil {
|
printer.PrintWarning(fmt.Sprintf("Download attempt %d/%d failed. Retrying...", i+1, retries+1))
|
||||||
printer.PrintWarning(fmt.Sprintf("failed to download export file: %v. Retrying...", err))
|
|
||||||
i++
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if retries != 0 && i == retries+1 {
|
return suggestedFilename, nil
|
||||||
return fmt.Errorf("failed to download export after %d retries", retries)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
|
func exportJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/v8"
|
"github.com/mattermost/mattermost/server/v8"
|
||||||
@@ -193,7 +194,7 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
|||||||
cmd.Flags().Int("num-retries", 5, "")
|
cmd.Flags().Int("num-retries", 5, "")
|
||||||
|
|
||||||
err := exportDownloadCmdF(s.th.Client, cmd, []string{exportName})
|
err := exportDownloadCmdF(s.th.Client, cmd, []string{exportName})
|
||||||
s.Require().EqualError(err, "failed to download export after 5 retries")
|
s.Require().EqualError(err, "failed to download export after 5 retries: You do not have the appropriate permissions.")
|
||||||
s.Require().Empty(printer.GetLines())
|
s.Require().Empty(printer.GetLines())
|
||||||
s.Require().Empty(printer.GetErrorLines())
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
})
|
})
|
||||||
@@ -227,7 +228,7 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
|||||||
defer os.Remove(downloadPath)
|
defer os.Remove(downloadPath)
|
||||||
|
|
||||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||||
s.Require().EqualError(err, "failed to download export after 5 retries")
|
s.Require().EqualError(err, "failed to download export after 5 retries: Unable to find export file.")
|
||||||
s.Require().Empty(printer.GetLines())
|
s.Require().Empty(printer.GetLines())
|
||||||
s.Require().Empty(printer.GetErrorLines())
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
})
|
})
|
||||||
@@ -252,7 +253,8 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
|||||||
|
|
||||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||||
s.Require().Nil(err)
|
s.Require().Nil(err)
|
||||||
s.Require().Empty(printer.GetLines())
|
s.Require().Len(printer.GetLines(), 1)
|
||||||
|
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
|
||||||
s.Require().Empty(printer.GetErrorLines())
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -273,7 +275,8 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
|||||||
|
|
||||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||||
s.Require().Nil(err)
|
s.Require().Nil(err)
|
||||||
s.Require().Empty(printer.GetLines())
|
s.Require().Len(printer.GetLines(), 1)
|
||||||
|
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
|
||||||
s.Require().Empty(printer.GetErrorLines())
|
s.Require().Empty(printer.GetErrorLines())
|
||||||
|
|
||||||
expected, err := os.ReadFile(exportFilePath)
|
expected, err := os.ReadFile(exportFilePath)
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ func (s *MmctlE2ETestSuite) SetupMessageExportTestHelper() *api4.TestHelper {
|
|||||||
s.th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
|
s.th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
|
||||||
messageExportImpl := message_export.MessageExportJobInterfaceImpl{Server: s.th.App.Srv()}
|
messageExportImpl := message_export.MessageExportJobInterfaceImpl{Server: s.th.App.Srv()}
|
||||||
s.th.App.Srv().Jobs.RegisterJobType(model.JobTypeMessageExport, messageExportImpl.MakeWorker(), messageExportImpl.MakeScheduler())
|
s.th.App.Srv().Jobs.RegisterJobType(model.JobTypeMessageExport, messageExportImpl.MakeWorker(), messageExportImpl.MakeScheduler())
|
||||||
|
s.th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.MessageExportSettings.DownloadExportResults = true
|
||||||
|
})
|
||||||
|
|
||||||
err := s.th.App.Srv().Jobs.StartWorkers()
|
err := s.th.App.Srv().Jobs.StartWorkers()
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ SEE ALSO
|
|||||||
|
|
||||||
* `mmctl <mmctl.rst>`_ - Remote client for the Open Source, self-hosted Slack-alternative
|
* `mmctl <mmctl.rst>`_ - Remote client for the Open Source, self-hosted Slack-alternative
|
||||||
* `mmctl compliance-export cancel <mmctl_compliance-export_cancel.rst>`_ - Cancel compliance export job
|
* `mmctl compliance-export cancel <mmctl_compliance-export_cancel.rst>`_ - Cancel compliance export job
|
||||||
|
* `mmctl compliance-export download <mmctl_compliance-export_download.rst>`_ - Download compliance export file
|
||||||
* `mmctl compliance-export list <mmctl_compliance-export_list.rst>`_ - List compliance export jobs, sorted by creation date descending (newest first)
|
* `mmctl compliance-export list <mmctl_compliance-export_list.rst>`_ - List compliance export jobs, sorted by creation date descending (newest first)
|
||||||
* `mmctl compliance-export show <mmctl_compliance-export_show.rst>`_ - Show compliance export job
|
* `mmctl compliance-export show <mmctl_compliance-export_show.rst>`_ - Show compliance export job
|
||||||
|
|
||||||
|
|||||||
52
server/cmd/mmctl/docs/mmctl_compliance-export_download.rst
Обычный файл
52
server/cmd/mmctl/docs/mmctl_compliance-export_download.rst
Обычный файл
@@ -0,0 +1,52 @@
|
|||||||
|
.. _mmctl_compliance-export_download:
|
||||||
|
|
||||||
|
mmctl compliance-export download
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
Download compliance export file
|
||||||
|
|
||||||
|
Synopsis
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
Download compliance export file
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
mmctl compliance-export download [complianceExportJobID] [output filepath (optional)] [flags]
|
||||||
|
|
||||||
|
Examples
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
compliance_export download o98rj3ur83dp5dppfyk5yk6osy
|
||||||
|
|
||||||
|
Options
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
-h, --help help for download
|
||||||
|
--num-retries int Number of retries if the download fails (default 5)
|
||||||
|
|
||||||
|
Options inherited from parent commands
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
--config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config")
|
||||||
|
--disable-pager disables paged output
|
||||||
|
--insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1
|
||||||
|
--insecure-tls-version allows to use TLS versions 1.0 and 1.1
|
||||||
|
--json the output format will be in json format
|
||||||
|
--local allows communicating with the server through a unix socket
|
||||||
|
--quiet prevent mmctl to generate output for the commands
|
||||||
|
--strict will only run commands if the mmctl version matches the server one
|
||||||
|
--suppress-warnings disables printing warning messages
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
* `mmctl compliance-export <mmctl_compliance-export.rst>`_ - Management of compliance exports
|
||||||
|
|
||||||
@@ -523,6 +523,21 @@ func (mr *MockClientMockRecorder) DoAPIPost(arg0, arg1, arg2 interface{}) *gomoc
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoAPIPost", reflect.TypeOf((*MockClient)(nil).DoAPIPost), arg0, arg1, arg2)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoAPIPost", reflect.TypeOf((*MockClient)(nil).DoAPIPost), arg0, arg1, arg2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadComplianceExport mocks base method.
|
||||||
|
func (m *MockClient) DownloadComplianceExport(arg0 context.Context, arg1 string, arg2 io.Writer) (string, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "DownloadComplianceExport", arg0, arg1, arg2)
|
||||||
|
ret0, _ := ret[0].(string)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadComplianceExport indicates an expected call of DownloadComplianceExport.
|
||||||
|
func (mr *MockClientMockRecorder) DownloadComplianceExport(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadComplianceExport", reflect.TypeOf((*MockClient)(nil).DownloadComplianceExport), arg0, arg1, arg2)
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadExport mocks base method.
|
// DownloadExport mocks base method.
|
||||||
func (m *MockClient) DownloadExport(arg0 context.Context, arg1 string, arg2 io.Writer, arg3 int64) (int64, *model.Response, error) {
|
func (m *MockClient) DownloadExport(arg0 context.Context, arg1 string, arg2 io.Writer, arg3 int64) (int64, *model.Response, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -1512,6 +1512,7 @@ func (c *Client4) GetUsersByIdsWithOptions(ctx context.Context, userIds []string
|
|||||||
return nil, BuildResponse(r), err
|
return nil, BuildResponse(r), err
|
||||||
}
|
}
|
||||||
defer closeBody(r)
|
defer closeBody(r)
|
||||||
|
|
||||||
var list []*User
|
var list []*User
|
||||||
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
|
||||||
return nil, nil, NewAppError("GetUsersByIdsWithOptions", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
return nil, nil, NewAppError("GetUsersByIdsWithOptions", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||||
@@ -3207,7 +3208,7 @@ func (c *Client4) PatchChannel(ctx context.Context, channelId string, patch *Cha
|
|||||||
var ch *Channel
|
var ch *Channel
|
||||||
err = json.NewDecoder(r.Body).Decode(&ch)
|
err = json.NewDecoder(r.Body).Decode(&ch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, BuildResponse(r), NewAppError("PatchChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
return nil, BuildResponse(r), NewAppError("PatchChannel", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||||
}
|
}
|
||||||
return ch, BuildResponse(r), nil
|
return ch, BuildResponse(r), nil
|
||||||
}
|
}
|
||||||
@@ -8927,6 +8928,31 @@ func (c *Client4) GetUserThreads(ctx context.Context, userId, teamId string, opt
|
|||||||
return &threads, BuildResponse(r), nil
|
return &threads, BuildResponse(r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client4) DownloadComplianceExport(ctx context.Context, jobId string, wr io.Writer) (string, error) {
|
||||||
|
r, err := c.DoAPIGet(ctx, c.jobsRoute()+fmt.Sprintf("/%s/download", jobId), "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer closeBody(r)
|
||||||
|
|
||||||
|
// Try to get the filename from the Content-Disposition header
|
||||||
|
var filename string
|
||||||
|
if cd := r.Header.Get("Content-Disposition"); cd != "" {
|
||||||
|
var params map[string]string
|
||||||
|
if _, params, err = mime.ParseMediaType(cd); err == nil {
|
||||||
|
if params["filename"] != "" {
|
||||||
|
filename = params["filename"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(wr, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return filename, NewAppError("DownloadComplianceExport", "model.client.copy.app_error", nil, "", r.StatusCode).Wrap(err)
|
||||||
|
}
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client4) GetUserThread(ctx context.Context, userId, teamId, threadId string, extended bool) (*ThreadResponse, *Response, error) {
|
func (c *Client4) GetUserThread(ctx context.Context, userId, teamId, threadId string, extended bool) (*ThreadResponse, *Response, error) {
|
||||||
url := c.userThreadRoute(userId, teamId, threadId)
|
url := c.userThreadRoute(userId, teamId, threadId)
|
||||||
if extended {
|
if extended {
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user