diff --git a/api/v4/source/imports.yaml b/api/v4/source/imports.yaml index c52484cd59..098318407a 100644 --- a/api/v4/source/imports.yaml +++ b/api/v4/source/imports.yaml @@ -22,3 +22,34 @@ $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + "/api/v4/imports/{import_name}": + delete: + tags: + - imports + summary: Delete an import file + description: | + Deletes an import file. + + + __Minimum server version__: 5.31 + + ##### Permissions + + Must have `manage_system` permissions. + operationId: DeleteImport + parameters: + - name: import_name + in: path + description: The name of the import file to delete + required: true + schema: + type: string + responses: + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 3879522ed9..58c6581f87 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -126,6 +126,7 @@ type Routes struct { Cloud *mux.Router // 'api/v4/cloud' Imports *mux.Router // 'api/v4/imports' + Import *mux.Router // 'api/v4/imports/{import_name:.+\\.zip}' Exports *mux.Router // 'api/v4/exports' Export *mux.Router // 'api/v4/exports/{export_name:.+\\.zip}' @@ -273,6 +274,7 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Cloud = api.BaseRoutes.APIRoot.PathPrefix("/cloud").Subrouter() api.BaseRoutes.Imports = api.BaseRoutes.APIRoot.PathPrefix("/imports").Subrouter() + api.BaseRoutes.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter() api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter() api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter() @@ -436,6 +438,7 @@ func InitLocal(srv *app.Server) *API { 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.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter() api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter() api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter() diff --git a/server/channels/api4/import.go b/server/channels/api4/import.go index 8b3842fdac..0009873618 100644 --- a/server/channels/api4/import.go +++ b/server/channels/api4/import.go @@ -9,10 +9,12 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/audit" ) func (api *API) InitImport() { api.BaseRoutes.Imports.Handle("", api.APISessionRequired(listImports)).Methods(http.MethodGet) + api.BaseRoutes.Import.Handle("", api.APISessionRequired(deleteImport)).Methods(http.MethodDelete) } func listImports(c *Context, w http.ResponseWriter, r *http.Request) { @@ -31,3 +33,23 @@ func listImports(c *Context, w http.ResponseWriter, r *http.Request) { c.Logger.Warn("Error writing imports", mlog.Err(err)) } } + +func deleteImport(c *Context, w http.ResponseWriter, r *http.Request) { + importName := c.Params.ImportName + auditRec := c.MakeAuditRecord("deleteImport", audit.Fail) + defer c.LogAuditRec(auditRec) + auditRec.AddMeta("import_name", importName) + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + if err := c.App.DeleteImport(importName); err != nil { + c.Err = err + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/server/channels/api4/import_local.go b/server/channels/api4/import_local.go index 8f52de36c6..099d89ba24 100644 --- a/server/channels/api4/import_local.go +++ b/server/channels/api4/import_local.go @@ -7,4 +7,5 @@ import "net/http" func (api *API) InitImportLocal() { api.BaseRoutes.Imports.Handle("", api.APILocal(listImports)).Methods(http.MethodGet) + api.BaseRoutes.Import.Handle("", api.APILocal(deleteImport)).Methods(http.MethodDelete) } diff --git a/server/channels/api4/import_test.go b/server/channels/api4/import_test.go index 3ecacbfa8b..35203238ee 100644 --- a/server/channels/api4/import_test.go +++ b/server/channels/api4/import_test.go @@ -19,42 +19,43 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" ) +// Helper function to upload a new import file +func uploadNewImport(th *TestHelper, c *model.Client4, t *testing.T) string { + testsDir, _ := fileutils.FindDir("tests") + require.NotEmpty(t, testsDir) + + file, err := os.Open(testsDir + "/import_test.zip") + require.NoError(t, err) + + info, err := file.Stat() + require.NoError(t, err) + + us := &model.UploadSession{ + Filename: info.Name(), + FileSize: info.Size(), + Type: model.UploadTypeImport, + } + + if c == th.LocalClient { + us.UserId = model.UploadNoUserID + } + + u, _, err := c.CreateUpload(context.Background(), us) + require.NoError(t, err) + require.NotNil(t, u) + + finfo, _, err := c.UploadData(context.Background(), u.Id, file) + require.NoError(t, err) + require.NotNil(t, finfo) + + return u.Id +} + func TestListImports(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() - testsDir, _ := fileutils.FindDir("tests") - require.NotEmpty(t, testsDir) - - uploadNewImport := func(c *model.Client4, t *testing.T) string { - file, err := os.Open(testsDir + "/import_test.zip") - require.NoError(t, err) - - info, err := file.Stat() - require.NoError(t, err) - - us := &model.UploadSession{ - Filename: info.Name(), - FileSize: info.Size(), - Type: model.UploadTypeImport, - } - - if c == th.LocalClient { - us.UserId = model.UploadNoUserID - } - - u, _, err := c.CreateUpload(context.Background(), us) - require.NoError(t, err) - require.NotNil(t, u) - - finfo, _, err := c.UploadData(context.Background(), u.Id, file) - require.NoError(t, err) - require.NotNil(t, finfo) - - return u.Id - } - t.Run("no permissions", func(t *testing.T) { imports, _, err := th.Client.ListImports(context.Background()) require.Error(t, err) @@ -71,8 +72,8 @@ func TestListImports(t *testing.T) { }, "no imports") th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { - id := uploadNewImport(c, t) - id2 := uploadNewImport(c, t) + id := uploadNewImport(th, c, t) + id2 := uploadNewImport(th, c, t) importDir := filepath.Join(dataDir, "import") f, err := os.Create(filepath.Join(importDir, "import.zip.tmp")) @@ -99,7 +100,7 @@ func TestListImports(t *testing.T) { require.NoError(t, err) require.Empty(t, imports) - id := uploadNewImport(c, t) + id := uploadNewImport(th, c, t) imports, _, err = c.ListImports(context.Background()) require.NoError(t, err) require.NotEmpty(t, imports) @@ -151,3 +152,36 @@ func TestImportInLocalMode(t *testing.T) { // Just a sanity check to ensure new posts are actually added in the system. require.Greater(t, cnt2, cnt1) } + +func TestDeleteImport(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + t.Run("no delete permissions", func(t *testing.T) { + response, err := th.Client.DeleteImport(context.Background(), "import_test.zip") + require.Error(t, err) + CheckErrorID(t, err, "api.context.permissions.app_error") + require.Equal(t, 403, response.StatusCode) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { + id := uploadNewImport(th, th.SystemAdminClient, t) + id2 := uploadNewImport(th, th.SystemAdminClient, t) + response, delErr := th.SystemAdminClient.DeleteImport(context.Background(), id+"_import_test.zip") + require.Equal(t, 200, response.StatusCode) + require.NoError(t, delErr) + imports, _, err := th.SystemAdminClient.ListImports(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, imports) + require.Len(t, imports, 1) + require.Contains(t, imports, id2+"_import_test.zip") + require.NotContains(t, imports, id+"_import_test.zip") + + _, err = th.SystemAdminClient.DeleteImport(context.Background(), id2+"_import_test.zip") + require.NoError(t, err) + + //idempotency check + _, err = th.SystemAdminClient.DeleteImport(context.Background(), id2+"_import_test.zip") + require.NoError(t, err) + }, "successful deletion") +} diff --git a/server/channels/app/import.go b/server/channels/app/import.go index 62ade80f9f..9b0ca925fd 100644 --- a/server/channels/app/import.go +++ b/server/channels/app/import.go @@ -410,3 +410,15 @@ func (a *App) ListImports() ([]string, *model.AppError) { return results, nil } + +func (a *App) DeleteImport(name string) *model.AppError { + filePath := filepath.Join(*a.Config().ImportSettings.Directory, name) + + if ok, err := a.FileExists(filePath); err != nil { + return err + } else if !ok { + return nil + } + + return a.RemoveFile(filePath) +} diff --git a/server/channels/app/import_test.go b/server/channels/app/import_test.go index 09c7f57fdf..8e9cd24b1f 100644 --- a/server/channels/app/import_test.go +++ b/server/channels/app/import_test.go @@ -679,3 +679,37 @@ func TestImportBulkImportWithAttachments(t *testing.T) { files := GetAttachments(adminUser.Id, th, t) require.Len(t, files, 11) } + +func TestDeleteImport(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + importDir := filepath.Join(th.tempWorkspace, "data", "import") + err := os.MkdirAll(importDir, os.ModePerm) + require.NoError(t, err) + f, err := os.Create(filepath.Join(importDir, "import.zip")) + require.NoError(t, err) + f.Close() + defer func() { + err = os.RemoveAll(importDir) + require.NoError(t, err) + }() + + t.Run("delete import successful", func(t *testing.T) { + imports, err := th.App.ListImports() + require.Nil(t, err) + require.Equal(t, 1, len(imports)) + require.Equal(t, "import.zip", imports[0]) + + delErr := th.App.DeleteImport("import.zip") + require.Nil(t, delErr) + + imports, err = th.App.ListImports() + require.Nil(t, err) + require.Equal(t, 0, len(imports)) + + //idempotency check + delErr = th.App.DeleteImport("import.zip") + require.Nil(t, delErr) + }) +} diff --git a/server/channels/web/params.go b/server/channels/web/params.go index b1c69d3d26..ad38e822fb 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -88,6 +88,7 @@ type Params struct { FilterParentTeamPermitted bool CategoryId string ExportName string + ImportName string ExcludePolicyConstrained bool GroupSource model.GroupSource FilterHasMember string @@ -278,6 +279,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.IncludeTotalCount, _ = strconv.ParseBool(query.Get("include_total_count")) params.IncludeDeleted, _ = strconv.ParseBool(query.Get("include_deleted")) params.ExportName = props["export_name"] + params.ImportName = props["import_name"] params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained")) params.AccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("access_control_policy_enforced")) params.ExcludeAccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("exclude_access_control_policy_enforced")) diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go index 6f5d17fc56..4660afca19 100644 --- a/server/cmd/mmctl/client/client.go +++ b/server/cmd/mmctl/client/client.go @@ -128,6 +128,7 @@ type Client interface { GetUploadsForUser(ctx context.Context, userID string) ([]*model.UploadSession, *model.Response, error) UploadData(ctx context.Context, uploadID string, data io.Reader) (*model.FileInfo, *model.Response, error) ListImports(ctx context.Context) ([]string, *model.Response, error) + DeleteImport(ctx context.Context, name string) (*model.Response, error) GetJob(ctx context.Context, id string) (*model.Job, *model.Response, error) GetJobs(ctx context.Context, jobType string, status string, page int, perPage int) ([]*model.Job, *model.Response, error) GetJobsByType(ctx context.Context, jobType string, page int, perPage int) ([]*model.Job, *model.Response, error) diff --git a/server/cmd/mmctl/commands/export_test.go b/server/cmd/mmctl/commands/export_test.go index 24db60d8a6..8d620dcfee 100644 --- a/server/cmd/mmctl/commands/export_test.go +++ b/server/cmd/mmctl/commands/export_test.go @@ -88,7 +88,6 @@ func (s *MmctlUnitTestSuite) TestExportCreateCmdF() { s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) }) } - func (s *MmctlUnitTestSuite) TestExportDeleteCmdF() { printer.Clean() diff --git a/server/cmd/mmctl/commands/import.go b/server/cmd/mmctl/commands/import.go index afdd6f0181..e83608e7dd 100644 --- a/server/cmd/mmctl/commands/import.go +++ b/server/cmd/mmctl/commands/import.go @@ -37,6 +37,14 @@ var ImportUploadCmd = &cobra.Command{ RunE: withClient(importUploadCmdF), } +var ImportDeleteCmd = &cobra.Command{ + Use: "delete [importname]", + Short: "Delete an import file", + Example: " import delete import_file.zip", + Args: cobra.ExactArgs(1), + RunE: withClient(importDeleteCmdF), +} + var ImportListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -130,6 +138,7 @@ func init() { ImportProcessCmd, ImportJobCmd, ImportValidateCmd, + ImportDeleteCmd, ) RootCmd.AddCommand(ImportCmd) } @@ -250,6 +259,17 @@ func importUploadCmdF(c client.Client, command *cobra.Command, args []string) er return nil } +func importDeleteCmdF(c client.Client, command *cobra.Command, args []string) error { + importName := args[0] + + if _, err := c.DeleteImport(context.TODO(), importName); err != nil { + return fmt.Errorf("failed to delete import: %w", err) + } + + printer.Print(fmt.Sprintf("Import file %q has been deleted", importName)) + return nil +} + func importProcessCmdF(c client.Client, command *cobra.Command, args []string) error { importFile := args[0] diff --git a/server/cmd/mmctl/commands/import_e2e_test.go b/server/cmd/mmctl/commands/import_e2e_test.go index 1a50556be3..3b822e1424 100644 --- a/server/cmd/mmctl/commands/import_e2e_test.go +++ b/server/cmd/mmctl/commands/import_e2e_test.go @@ -5,6 +5,7 @@ package commands import ( "context" + "fmt" "os" "path/filepath" "time" @@ -14,6 +15,7 @@ import ( "github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/utils" "github.com/spf13/cobra" ) @@ -430,3 +432,53 @@ func (s *MmctlE2ETestSuite) TestImportValidateCmdF() { s.Require().Equal("Validation complete\n", printer.GetLines()[2]) }) } + +func (s *MmctlE2ETestSuite) TestImportDeleteCmdF() { + s.SetupTestHelper().InitBasic() + s.Run("no permissions", func() { + printer.Clean() + + err := importDeleteCmdF(s.th.Client, &cobra.Command{}, []string{"import1.zip"}) + s.Require().EqualError(err, "failed to delete import: You do not have the appropriate permissions.") + s.Require().Empty(printer.GetLines()) + s.Require().Empty(printer.GetErrorLines()) + }) + + s.RunForSystemAdminAndLocal("delete import", func(c client.Client) { + importName := "import_test.zip" + importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName) + importPath, err := filepath.Abs(filepath.Join(*s.th.App.Config().FileSettings.Directory, + *s.th.App.Config().ImportSettings.Directory)) + s.Require().Nil(err) + + cmd := &cobra.Command{} + + newImportName := "new_import_test.zip" + err = utils.CopyFile(importFilePath, filepath.Join(importPath, newImportName)) + s.Require().Nil(err) + + printer.Clean() + imports, appErr := s.th.App.ListImports() + s.Require().Nil(appErr) + s.Require().NotEmpty(imports) + s.Require().Equal(newImportName, imports[0]) + + err = importDeleteCmdF(c, cmd, []string{newImportName}) + s.Require().Nil(err) + s.Require().Empty(printer.GetErrorLines()) + s.Require().Len(printer.GetLines(), 1) + s.Equal(fmt.Sprintf(`Import file "%s" has been deleted`, newImportName), printer.GetLines()[0]) + + imports, appErr = s.th.App.ListImports() + s.Require().Nil(appErr) + s.Require().Empty(imports) + + //idempotency check + + err = importDeleteCmdF(c, cmd, []string{newImportName}) + s.Require().Nil(err) + s.Require().Empty(printer.GetErrorLines()) + s.Require().Len(printer.GetLines(), 2) + s.Equal(fmt.Sprintf(`Import file "%s" has been deleted`, newImportName), printer.GetLines()[0]) + }) +} diff --git a/server/cmd/mmctl/commands/import_test.go b/server/cmd/mmctl/commands/import_test.go index 8ca40a3c7f..eca00791ee 100644 --- a/server/cmd/mmctl/commands/import_test.go +++ b/server/cmd/mmctl/commands/import_test.go @@ -536,3 +536,25 @@ func (s *MmctlUnitTestSuite) TestImportValidateCmdF() { s.Equal("Validation complete\n", printer.GetLines()[2]) }) } + +func (s *MmctlUnitTestSuite) TestDeleteImportCmdF() { + s.Run("delete command succeeds", func() { + printer.Clean() + s.client. + EXPECT(). + DeleteImport(context.TODO(), "import.zip"). + Return(&model.Response{}, nil). + Times(2) + + err := importDeleteCmdF(s.client, &cobra.Command{}, []string{"import.zip"}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal("Import file \"import.zip\" has been deleted", printer.GetLines()[0]) + + //idempotency check + err = importDeleteCmdF(s.client, &cobra.Command{}, []string{"import.zip"}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 2) + s.Equal("Import file \"import.zip\" has been deleted", printer.GetLines()[1]) + }) +} diff --git a/server/cmd/mmctl/docs/mmctl_import.rst b/server/cmd/mmctl/docs/mmctl_import.rst index ab1d752de9..9d2e60988e 100644 --- a/server/cmd/mmctl/docs/mmctl_import.rst +++ b/server/cmd/mmctl/docs/mmctl_import.rst @@ -37,6 +37,7 @@ SEE ALSO ~~~~~~~~ * `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl import delete `_ - Delete an import file * `mmctl import job `_ - List and show import jobs * `mmctl import list `_ - List import files * `mmctl import process `_ - Start an import job diff --git a/server/cmd/mmctl/docs/mmctl_import_delete.rst b/server/cmd/mmctl/docs/mmctl_import_delete.rst new file mode 100644 index 0000000000..35eb077b28 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_delete.rst @@ -0,0 +1,51 @@ +.. _mmctl_import_delete: + +mmctl import delete +------------------- + +Delete an import file + +Synopsis +~~~~~~~~ + + +Delete an import file + +:: + + mmctl import delete [importname] [flags] + +Examples +~~~~~~~~ + +:: + + import delete import_file.zip + +Options +~~~~~~~ + +:: + + -h, --help help for delete + +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 import `_ - Management of imports + diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go index adb61db957..d9366fef7a 100644 --- a/server/cmd/mmctl/mocks/client_mock.go +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -387,6 +387,21 @@ func (mr *MockClientMockRecorder) DeleteExport(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExport", reflect.TypeOf((*MockClient)(nil).DeleteExport), arg0, arg1) } +// DeleteImport mocks base method. +func (m *MockClient) DeleteImport(arg0 context.Context, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteImport", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteImport indicates an expected call of DeleteImport. +func (mr *MockClientMockRecorder) DeleteImport(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteImport", reflect.TypeOf((*MockClient)(nil).DeleteImport), arg0, arg1) +} + // DeleteIncomingWebhook mocks base method. func (m *MockClient) DeleteIncomingWebhook(arg0 context.Context, arg1 string) (*model.Response, error) { m.ctrl.T.Helper() diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 4c655a8a0f..5e9c22bbb5 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -574,6 +574,10 @@ func (c *Client4) exportRoute(name string) string { return fmt.Sprintf(c.exportsRoute()+"/%v", name) } +func (c *Client4) importRoute(name string) string { + return fmt.Sprintf(c.importsRoute()+"/%v", name) +} + func (c *Client4) remoteClusterRoute() string { return "/remotecluster" } @@ -8798,6 +8802,15 @@ func (c *Client4) ListImports(ctx context.Context) ([]string, *Response, error) return c.ArrayFromJSON(r.Body), BuildResponse(r), nil } +func (c *Client4) DeleteImport(ctx context.Context, name string) (*Response, error) { + r, err := c.DoAPIDelete(ctx, c.importRoute(name)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + func (c *Client4) ListExports(ctx context.Context) ([]string, *Response, error) { r, err := c.DoAPIGet(ctx, c.exportsRoute(), "") if err != nil {