[MM-56073] MMCTL delete post command (#27539)
Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com>
Этот коммит содержится в:
@@ -626,13 +626,28 @@ func deletePost(c *Context, w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
permanent := c.Params.Permanent
|
||||
|
||||
auditRec := c.MakeAuditRecord("deletePost", audit.Fail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
audit.AddEventParameter(auditRec, "post_id", c.Params.PostId)
|
||||
audit.AddEventParameter(auditRec, "permanent", permanent)
|
||||
|
||||
post, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
|
||||
if err != nil {
|
||||
c.SetPermissionError(model.PermissionDeletePost)
|
||||
includeDeleted := permanent
|
||||
|
||||
if permanent && !*c.App.Config().ServiceSettings.EnableAPIPostDeletion {
|
||||
c.Err = model.NewAppError("deletePost", "api.post.delete_post.not_enabled.app_error", nil, "postId="+c.Params.PostId, http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if permanent && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
post, appErr := c.App.GetSinglePost(c.AppContext, c.Params.PostId, includeDeleted)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
auditRec.AddEventPriorState(post)
|
||||
@@ -650,8 +665,14 @@ func deletePost(c *Context, w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := c.App.DeletePost(c.AppContext, c.Params.PostId, c.AppContext.Session().UserId); err != nil {
|
||||
c.Err = err
|
||||
if permanent {
|
||||
appErr = c.App.PermanentDeletePost(c.AppContext, c.Params.PostId, c.AppContext.Session().UserId)
|
||||
} else {
|
||||
_, appErr = c.App.DeletePost(c.AppContext, c.Params.PostId, c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,53 @@
|
||||
|
||||
package api4
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/audit"
|
||||
)
|
||||
|
||||
func (api *API) InitPostLocal() {
|
||||
api.BaseRoutes.Post.Handle("", api.APILocal(getPost)).Methods(http.MethodGet)
|
||||
|
||||
api.BaseRoutes.PostsForChannel.Handle("", api.APILocal(getPostsForChannel)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Post.Handle("", api.APILocal(localDeletePost)).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func localDeletePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
permanent := c.Params.Permanent
|
||||
|
||||
auditRec := c.MakeAuditRecord("localDeletePost", audit.Fail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
audit.AddEventParameter(auditRec, "post_id", c.Params.PostId)
|
||||
audit.AddEventParameter(auditRec, "permanent", permanent)
|
||||
|
||||
includeDeleted := permanent
|
||||
|
||||
post, appErr := c.App.GetSinglePost(c.AppContext, c.Params.PostId, includeDeleted)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
auditRec.AddEventPriorState(post)
|
||||
auditRec.AddEventObjectType("post")
|
||||
|
||||
if permanent {
|
||||
appErr = c.App.PermanentDeletePost(c.AppContext, c.Params.PostId, c.AppContext.Session().UserId)
|
||||
} else {
|
||||
_, appErr = c.App.DeletePost(c.AppContext, c.Params.PostId, c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
@@ -2817,39 +2817,124 @@ func TestDeletePost(t *testing.T) {
|
||||
defer th.TearDown()
|
||||
client := th.Client
|
||||
|
||||
resp, err := client.DeletePost(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
t.Run("Post not found", func(t *testing.T) {
|
||||
resp, err := client.DeletePost(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
resp, err = client.DeletePost(context.Background(), "junk")
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
t.Run("Post doesn't exist", func(t *testing.T) {
|
||||
resp, err := client.DeletePost(context.Background(), "junk")
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
resp, err = client.DeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
t.Run("No permissions to delete a post", func(t *testing.T) {
|
||||
resp, err := client.DeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
client.Login(context.Background(), th.TeamAdminUser.Email, th.TeamAdminUser.Password)
|
||||
_, err = client.DeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.NoError(t, err)
|
||||
t.Run("Try to delete a post across different user roles", func(t *testing.T) {
|
||||
client.Login(context.Background(), th.TeamAdminUser.Email, th.TeamAdminUser.Password)
|
||||
_, cErr := client.DeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.NoError(t, cErr)
|
||||
|
||||
post := th.CreatePost()
|
||||
user := th.CreateUser()
|
||||
post := th.CreatePost()
|
||||
post2 := th.CreatePost()
|
||||
user := th.CreateUser()
|
||||
|
||||
client.Logout(context.Background())
|
||||
client.Login(context.Background(), user.Email, user.Password)
|
||||
client.Logout(context.Background())
|
||||
client.Login(context.Background(), user.Email, user.Password)
|
||||
|
||||
resp, err = client.DeletePost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
resp, err := client.DeletePost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
client.Logout(context.Background())
|
||||
resp, err = client.DeletePost(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
client.Logout(context.Background())
|
||||
resp, err = client.DeletePost(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
|
||||
_, err = th.SystemAdminClient.DeletePost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
_, err = th.SystemAdminClient.DeletePost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.LocalClient.DeletePost(context.Background(), post2.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermanentDeletePost(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
client := th.Client
|
||||
|
||||
enableAPIPostDeletion := *th.App.Config().ServiceSettings.EnableAPIPostDeletion
|
||||
defer func() {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableAPIPostDeletion = &enableAPIPostDeletion })
|
||||
}()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIPostDeletion = false })
|
||||
|
||||
t.Run("Post not found", func(t *testing.T) {
|
||||
resp, err := client.PermanentDeletePost(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Post doesn't exist", func(t *testing.T) {
|
||||
resp, err := client.PermanentDeletePost(context.Background(), "junk")
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Permanent deletion not available through API if EnableAPIPostDeletion is not set", func(t *testing.T) {
|
||||
resp, err := th.SystemAdminClient.PermanentDeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Permanent deletion available through local mode even if EnableAPIPostDeletion is not set", func(t *testing.T) {
|
||||
post := th.CreatePost()
|
||||
_, err := th.LocalClient.PermanentDeletePost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("No permissions to permanently delete a post", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIPostDeletion = true })
|
||||
resp, err := client.PermanentDeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Try to permanently delete a post across different user roles", func(t *testing.T) {
|
||||
client.Login(context.Background(), th.TeamAdminUser.Email, th.TeamAdminUser.Password)
|
||||
resp, err := client.PermanentDeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
post := th.CreatePost()
|
||||
post2 := th.CreatePost()
|
||||
user := th.CreateUser()
|
||||
|
||||
client.Logout(context.Background())
|
||||
client.Login(context.Background(), user.Email, user.Password)
|
||||
|
||||
resp, err = client.PermanentDeletePost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
client.Logout(context.Background())
|
||||
resp, err = client.PermanentDeletePost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
|
||||
_, err = th.SystemAdminClient.PermanentDeletePost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.LocalClient.PermanentDeletePost(context.Background(), post2.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeletePostEvent(t *testing.T) {
|
||||
|
||||
@@ -491,6 +491,7 @@ type AppIface interface {
|
||||
CheckUserPostflightAuthenticationCriteria(rctx request.CTX, user *model.User) *model.AppError
|
||||
CheckUserPreflightAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError
|
||||
CheckWebConn(userID, connectionID string) *platform.CheckConnResult
|
||||
CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteByID string) *model.AppError
|
||||
CleanupReportChunks(format string, prefix string, numberOfChunks int) *model.AppError
|
||||
ClearChannelMembersCache(c request.CTX, channelID string) error
|
||||
ClearLatestVersionCache(rctx request.CTX)
|
||||
@@ -579,7 +580,7 @@ type AppIface interface {
|
||||
DeleteOAuthApp(rctx request.CTX, appID string) *model.AppError
|
||||
DeleteOutgoingWebhook(hookID string) *model.AppError
|
||||
DeletePluginKey(pluginID string, key string) *model.AppError
|
||||
DeletePost(c request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
DeletePreferences(c request.CTX, userID string, preferences model.Preferences) *model.AppError
|
||||
DeleteReactionForPost(c request.CTX, reaction *model.Reaction) *model.AppError
|
||||
DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError)
|
||||
@@ -986,9 +987,11 @@ type AppIface interface {
|
||||
PatchUser(c request.CTX, userID string, patch *model.UserPatch, asAdmin bool) (*model.User, *model.AppError)
|
||||
PermanentDeleteAllUsers(c request.CTX) *model.AppError
|
||||
PermanentDeleteChannel(c request.CTX, channel *model.Channel) *model.AppError
|
||||
PermanentDeleteFilesByPost(rctx request.CTX, postID string) *model.AppError
|
||||
PermanentDeletePost(rctx request.CTX, postID, deleteByID string) *model.AppError
|
||||
PermanentDeleteTeam(c request.CTX, team *model.Team) *model.AppError
|
||||
PermanentDeleteTeamId(c request.CTX, teamID string) *model.AppError
|
||||
PermanentDeleteUser(c request.CTX, user *model.User) *model.AppError
|
||||
PermanentDeleteUser(rctx request.CTX, user *model.User) *model.AppError
|
||||
PostActionCookieSecret() []byte
|
||||
PostAddToChannelMessage(c request.CTX, user *model.User, addedUser *model.User, channel *model.Channel, postRootId string) *model.AppError
|
||||
PostPatchWithProxyRemovedFromImageURLs(patch *model.PostPatch) *model.PostPatch
|
||||
@@ -1022,6 +1025,8 @@ type AppIface interface {
|
||||
RemoveDirectory(path string) *model.AppError
|
||||
RemoveExportFile(path string) *model.AppError
|
||||
RemoveFile(path string) *model.AppError
|
||||
RemoveFileFromFileStore(rctx request.CTX, path string)
|
||||
RemoveFilesFromFileStore(rctx request.CTX, fileInfos []*model.FileInfo)
|
||||
RemoveLdapPrivateCertificate() *model.AppError
|
||||
RemoveLdapPublicCertificate() *model.AppError
|
||||
RemoveNotifications(c request.CTX, post *model.Post, channel *model.Channel) error
|
||||
|
||||
@@ -1594,3 +1594,65 @@ func getFileExtFromMimeType(mimeType string) string {
|
||||
}
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeleteFilesByPost(rctx request.CTX, postID string) *model.AppError {
|
||||
fileInfos, err := a.Srv().Store().FileInfo().GetForPost(postID, false, true, true)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentDeleteFilesByPost", "app.file_info.get_by_post_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if len(fileInfos) == 0 {
|
||||
rctx.Logger().Debug("No files found for post", mlog.String("post_id", postID))
|
||||
return nil
|
||||
}
|
||||
|
||||
a.RemoveFilesFromFileStore(rctx, fileInfos)
|
||||
|
||||
err = a.Srv().Store().FileInfo().PermanentDeleteForPost(rctx, postID)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentDeleteFilesByPost", "app.file_info.permanent_delete_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, true)
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RemoveFilesFromFileStore(rctx request.CTX, fileInfos []*model.FileInfo) {
|
||||
for _, info := range fileInfos {
|
||||
a.RemoveFileFromFileStore(rctx, info.Path)
|
||||
if info.PreviewPath != "" {
|
||||
a.RemoveFileFromFileStore(rctx, info.PreviewPath)
|
||||
}
|
||||
if info.ThumbnailPath != "" {
|
||||
a.RemoveFileFromFileStore(rctx, info.ThumbnailPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
||||
res, appErr := a.FileExists(path)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn(
|
||||
"Error checking existence of file",
|
||||
mlog.String("path", path),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if !res {
|
||||
rctx.Logger().Warn("File not found", mlog.String("path", path))
|
||||
return
|
||||
}
|
||||
|
||||
appErr = a.RemoveFile(path)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn(
|
||||
"Unable to remove file",
|
||||
mlog.String("path", path),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,3 +730,59 @@ func TestSetFileSearchableContent(t *testing.T) {
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, 1, len(result.Order))
|
||||
}
|
||||
|
||||
func TestPermanentDeleteFilesByPost(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("should delete files for post", func(t *testing.T) {
|
||||
// Create a post with a file attachment.
|
||||
teamID := th.BasicTeam.Id
|
||||
channelID := th.BasicChannel.Id
|
||||
userID := th.BasicUser.Id
|
||||
filename := "test"
|
||||
data := []byte("abcd")
|
||||
|
||||
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
post := &model.Post{
|
||||
Message: "asd",
|
||||
ChannelId: channelID,
|
||||
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
|
||||
UserId: userID,
|
||||
CreateAt: 0,
|
||||
FileIds: []string{info1.Id},
|
||||
}
|
||||
|
||||
post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = th.App.GetFileInfo(th.Context, info1.Id)
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should not delete files for post that doesn't exist", func(t *testing.T) {
|
||||
err := th.App.PermanentDeleteFilesByPost(th.Context, "postId1")
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should handle empty file list", func(t *testing.T) {
|
||||
post := &model.Post{
|
||||
Message: "asd",
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
|
||||
UserId: th.BasicUser.Id,
|
||||
CreateAt: 0,
|
||||
}
|
||||
|
||||
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1455,6 +1455,28 @@ func (a *OpenTracingAppLayer) CheckWebConn(userID string, connectionID string) *
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteByID string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CleanUpAfterPostDeletion")
|
||||
|
||||
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.CleanUpAfterPostDeletion(c, post, deleteByID)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CleanupReportChunks(format string, prefix string, numberOfChunks int) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CleanupReportChunks")
|
||||
@@ -3588,7 +3610,7 @@ func (a *OpenTracingAppLayer) DeletePluginKey(pluginID string, key string) *mode
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeletePost(c request.CTX, postID string, deleteByID string) (*model.Post, *model.AppError) {
|
||||
func (a *OpenTracingAppLayer) DeletePost(rctx request.CTX, postID string, deleteByID string) (*model.Post, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeletePost")
|
||||
|
||||
@@ -3600,7 +3622,7 @@ func (a *OpenTracingAppLayer) DeletePost(c request.CTX, postID string, deleteByI
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.DeletePost(c, postID, deleteByID)
|
||||
resultVar0, resultVar1 := a.app.DeletePost(rctx, postID, deleteByID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
@@ -13618,6 +13640,50 @@ func (a *OpenTracingAppLayer) PermanentDeleteChannel(c request.CTX, channel *mod
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) PermanentDeleteFilesByPost(rctx request.CTX, postID string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PermanentDeleteFilesByPost")
|
||||
|
||||
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.PermanentDeleteFilesByPost(rctx, postID)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) PermanentDeletePost(rctx request.CTX, postID string, deleteByID string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PermanentDeletePost")
|
||||
|
||||
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.PermanentDeletePost(rctx, postID, deleteByID)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) PermanentDeleteTeam(c request.CTX, team *model.Team) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PermanentDeleteTeam")
|
||||
@@ -13662,7 +13728,7 @@ func (a *OpenTracingAppLayer) PermanentDeleteTeamId(c request.CTX, teamID string
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppError {
|
||||
func (a *OpenTracingAppLayer) PermanentDeleteUser(rctx request.CTX, user *model.User) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PermanentDeleteUser")
|
||||
|
||||
@@ -13674,7 +13740,7 @@ func (a *OpenTracingAppLayer) PermanentDeleteUser(c request.CTX, user *model.Use
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.PermanentDeleteUser(c, user)
|
||||
resultVar0 := a.app.PermanentDeleteUser(rctx, user)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
@@ -14454,6 +14520,36 @@ func (a *OpenTracingAppLayer) RemoveFile(path string) *model.AppError {
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RemoveFileFromFileStore")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
a.app.RemoveFileFromFileStore(rctx, path)
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) RemoveFilesFromFileStore(rctx request.CTX, fileInfos []*model.FileInfo) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RemoveFilesFromFileStore")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
a.app.RemoveFilesFromFileStore(rctx, fileInfos)
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) RemoveLdapPrivateCertificate() *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RemoveLdapPrivateCertificate")
|
||||
|
||||
@@ -1361,23 +1361,22 @@ func (a *App) GetPostsForChannelAroundLastUnread(c request.CTX, channelID, userI
|
||||
return postList, nil
|
||||
}
|
||||
|
||||
func (a *App) DeletePost(c request.CTX, postID, deleteByID string) (*model.Post, *model.AppError) {
|
||||
post, err := a.Srv().Store().Post().GetSingle(sqlstore.RequestContextWithMaster(c), postID, false)
|
||||
func (a *App) DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError) {
|
||||
post, err := a.Srv().Store().Post().GetSingle(sqlstore.RequestContextWithMaster(rctx), postID, false)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("DeletePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
|
||||
channel, appErr := a.GetChannel(c, post.ChannelId)
|
||||
channel, appErr := a.GetChannel(rctx, post.ChannelId)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if channel.DeleteAt != 0 {
|
||||
appErr := model.NewAppError("DeletePost", "api.post.delete_post.can_not_delete_post_in_deleted.error", nil, "", http.StatusBadRequest)
|
||||
return nil, appErr
|
||||
return nil, model.NewAppError("DeletePost", "api.post.delete_post.can_not_delete_post_in_deleted.error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
err = a.Srv().Store().Post().Delete(c, postID, model.GetMillis(), deleteByID)
|
||||
err = a.Srv().Store().Post().Delete(rctx, postID, model.GetMillis(), deleteByID)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
switch {
|
||||
@@ -1388,60 +1387,18 @@ func (a *App) DeletePost(c request.CTX, postID, deleteByID string) (*model.Post,
|
||||
}
|
||||
}
|
||||
|
||||
if post.RootId == "" {
|
||||
if appErr := a.DeletePersistentNotification(c, post); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
postJSON, err := json.Marshal(post)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
userMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
||||
userMessage.Add("post", string(postJSON))
|
||||
userMessage.GetBroadcast().ContainsSanitizedData = true
|
||||
a.Publish(userMessage)
|
||||
|
||||
adminMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
||||
adminMessage.Add("post", string(postJSON))
|
||||
adminMessage.Add("delete_by", deleteByID)
|
||||
adminMessage.GetBroadcast().ContainsSensitiveData = true
|
||||
a.Publish(adminMessage)
|
||||
|
||||
if len(post.FileIds) > 0 {
|
||||
a.Srv().Go(func() {
|
||||
a.deletePostFiles(c, post.Id)
|
||||
a.deletePostFiles(rctx, post.Id)
|
||||
})
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, true)
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, false)
|
||||
}
|
||||
a.Srv().Go(func() {
|
||||
a.deleteFlaggedPosts(c, post.Id)
|
||||
})
|
||||
|
||||
pluginPost := post.ForPlugin()
|
||||
pluginContext := pluginContext(c)
|
||||
a.Srv().Go(func() {
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks) bool {
|
||||
hooks.MessageHasBeenDeleted(pluginContext, pluginPost)
|
||||
return true
|
||||
}, plugin.MessageHasBeenDeletedID)
|
||||
})
|
||||
|
||||
a.Srv().Go(func() {
|
||||
if err = a.RemoveNotifications(c, post, channel); err != nil {
|
||||
c.Logger().Error("DeletePost failed to delete notification", mlog.Err(err))
|
||||
}
|
||||
})
|
||||
|
||||
// delete drafts associated with the post when deleting the post
|
||||
a.Srv().Go(func() {
|
||||
a.deleteDraftsAssociatedWithPost(c, channel, post)
|
||||
})
|
||||
|
||||
a.invalidateCacheForChannelPosts(post.ChannelId)
|
||||
appErr = a.CleanUpAfterPostDeletion(rctx, post, deleteByID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
@@ -2646,3 +2603,86 @@ func (a *App) MoveThread(c request.CTX, postID string, sourceChannelID, channelI
|
||||
c.Logger().Info(msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeletePost(rctx request.CTX, postID, deleteByID string) *model.AppError {
|
||||
post, err := a.Srv().Store().Post().GetSingle(sqlstore.RequestContextWithMaster(rctx), postID, true)
|
||||
if err != nil {
|
||||
return model.NewAppError("DeletePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
|
||||
if len(post.FileIds) > 0 {
|
||||
appErr := a.PermanentDeleteFilesByPost(rctx, post.Id)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
err = a.Srv().Store().Post().PermanentDelete(rctx, post.Id)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentDeletePost", "app.post.permanent_delete_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
appErr := a.CleanUpAfterPostDeletion(rctx, post, deleteByID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteByID string) *model.AppError {
|
||||
channel, appErr := a.GetChannel(c, post.ChannelId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if post.RootId == "" {
|
||||
if appErr := a.DeletePersistentNotification(c, post); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
postJSON, err := json.Marshal(post)
|
||||
if err != nil {
|
||||
return model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
userMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
||||
userMessage.Add("post", string(postJSON))
|
||||
userMessage.GetBroadcast().ContainsSanitizedData = true
|
||||
a.Publish(userMessage)
|
||||
|
||||
adminMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
||||
adminMessage.Add("post", string(postJSON))
|
||||
adminMessage.Add("delete_by", deleteByID)
|
||||
adminMessage.GetBroadcast().ContainsSensitiveData = true
|
||||
a.Publish(adminMessage)
|
||||
|
||||
a.Srv().Go(func() {
|
||||
a.deleteFlaggedPosts(c, post.Id)
|
||||
})
|
||||
|
||||
pluginPost := post.ForPlugin()
|
||||
pluginContext := pluginContext(c)
|
||||
a.Srv().Go(func() {
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks) bool {
|
||||
hooks.MessageHasBeenDeleted(pluginContext, pluginPost)
|
||||
return true
|
||||
}, plugin.MessageHasBeenDeletedID)
|
||||
})
|
||||
|
||||
a.Srv().Go(func() {
|
||||
if err = a.RemoveNotifications(c, post, channel); err != nil {
|
||||
c.Logger().Error("DeletePost failed to delete notification", mlog.Err(err))
|
||||
}
|
||||
})
|
||||
|
||||
// delete drafts associated with the post when deleting the post
|
||||
a.Srv().Go(func() {
|
||||
a.deleteDraftsAssociatedWithPost(c, channel, post)
|
||||
})
|
||||
|
||||
a.invalidateCacheForChannelPosts(post.ChannelId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3569,3 +3569,94 @@ func TestValidateMoveOrCopy(t *testing.T) {
|
||||
require.Equal(t, "the thread is 2 posts long, but this command is configured to only move threads of up to 1 posts", e.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermanentDeletePost(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("should permanently delete a post and its file attachment", func(t *testing.T) {
|
||||
// Create a post with a file attachment.
|
||||
teamID := th.BasicTeam.Id
|
||||
channelID := th.BasicChannel.Id
|
||||
userID := th.BasicUser.Id
|
||||
filename := "test"
|
||||
data := []byte("abcd")
|
||||
|
||||
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
post := &model.Post{
|
||||
Message: "asd",
|
||||
ChannelId: channelID,
|
||||
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
|
||||
UserId: userID,
|
||||
CreateAt: 0,
|
||||
FileIds: []string{info1.Id},
|
||||
}
|
||||
|
||||
post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Delete the post.
|
||||
err = th.App.PermanentDeletePost(th.Context, post.Id, userID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Wait for the cleanup routine to finish.
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Check that the post can no longer be reached.
|
||||
_, err = th.App.GetSinglePost(th.Context, post.Id, true)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
// Check that the file can no longer be reached.
|
||||
_, err = th.App.GetFileInfo(th.Context, info1.Id)
|
||||
assert.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should permanently delete a post that is soft deleted", func(t *testing.T) {
|
||||
// Create a post with a file attachment.
|
||||
teamID := th.BasicTeam.Id
|
||||
channelID := th.BasicChannel.Id
|
||||
userID := th.BasicUser.Id
|
||||
filename := "test"
|
||||
data := []byte("abcd")
|
||||
|
||||
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
post := &model.Post{
|
||||
Message: "asd",
|
||||
ChannelId: channelID,
|
||||
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
|
||||
UserId: userID,
|
||||
CreateAt: 0,
|
||||
FileIds: []string{info1.Id},
|
||||
}
|
||||
|
||||
post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
infos, sErr := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false)
|
||||
require.NoError(t, sErr)
|
||||
assert.Len(t, infos, 1)
|
||||
|
||||
// Soft delete the post.
|
||||
_, err = th.App.DeletePost(th.Context, post.Id, userID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Wait for the cleanup routine to finish.
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Delete the post.
|
||||
err = th.App.PermanentDeletePost(th.Context, post.Id, userID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Check that the post can no longer be reached.
|
||||
_, err = th.App.GetSinglePost(th.Context, post.Id, true)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
infos, sErr = th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false)
|
||||
require.NoError(t, sErr)
|
||||
assert.Len(t, infos, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1759,13 +1759,13 @@ func (a *App) UpdateUserRolesWithUser(c request.CTX, user *model.User, newRoles
|
||||
return ruser, nil
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppError {
|
||||
c.Logger().Warn("Attempting to permanently delete account", mlog.String("user_id", user.Id), mlog.String("user_email", user.Email))
|
||||
func (a *App) PermanentDeleteUser(rctx request.CTX, user *model.User) *model.AppError {
|
||||
rctx.Logger().Warn("Attempting to permanently delete account", mlog.String("user_id", user.Id), mlog.String("user_email", user.Email))
|
||||
if user.IsInRole(model.SystemAdminRoleId) {
|
||||
c.Logger().Warn("You are deleting a user that is a system administrator. You may need to set another account as the system administrator using the command line tools.", mlog.String("user_email", user.Email))
|
||||
rctx.Logger().Warn("You are deleting a user that is a system administrator. You may need to set another account as the system administrator using the command line tools.", mlog.String("user_email", user.Email))
|
||||
}
|
||||
|
||||
if _, err := a.UpdateActive(c, user, false); err != nil {
|
||||
if _, err := a.UpdateActive(rctx, user, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1797,7 +1797,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
return model.NewAppError("PermanentDeleteUser", "app.preference.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Channel().PermanentDeleteMembersByUser(c, user.Id); err != nil {
|
||||
if err := a.Srv().Store().Channel().PermanentDeleteMembersByUser(rctx, user.Id); err != nil {
|
||||
return model.NewAppError("PermanentDeleteUser", "app.channel.permanent_delete_members_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
@@ -1805,7 +1805,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
return model.NewAppError("PermanentDeleteUser", "app.group.permanent_delete_members_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Post().PermanentDeleteByUser(c, user.Id); err != nil {
|
||||
if err := a.Srv().Store().Post().PermanentDeleteByUser(rctx, user.Id); err != nil {
|
||||
return model.NewAppError("PermanentDeleteUser", "app.post.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
@@ -1825,35 +1825,10 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
|
||||
infos, err := a.Srv().Store().FileInfo().GetForUser(user.Id)
|
||||
if err != nil {
|
||||
c.Logger().Warn("Error getting file list for user from FileInfoStore", mlog.Err(err))
|
||||
rctx.Logger().Warn("Error getting file list for user from FileInfoStore", mlog.Err(err))
|
||||
}
|
||||
|
||||
for _, info := range infos {
|
||||
res, err := a.FileExists(info.Path)
|
||||
if err != nil {
|
||||
c.Logger().Warn(
|
||||
"Error checking existence of file",
|
||||
mlog.String("path", info.Path),
|
||||
mlog.Err(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if !res {
|
||||
c.Logger().Warn("File not found", mlog.String("path", info.Path))
|
||||
continue
|
||||
}
|
||||
|
||||
err = a.RemoveFile(info.Path)
|
||||
|
||||
if err != nil {
|
||||
c.Logger().Warn(
|
||||
"Unable to remove file",
|
||||
mlog.String("path", info.Path),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
a.RemoveFilesFromFileStore(rctx, infos)
|
||||
|
||||
// delete directory containing user's profile image
|
||||
profileImageDirectory := getProfileImageDirectory(user.Id)
|
||||
@@ -1864,7 +1839,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
|
||||
if errProfileImageExists != nil {
|
||||
fileHandlingErrorsFound = true
|
||||
c.Logger().Warn(
|
||||
rctx.Logger().Warn(
|
||||
"Error checking existence of profile image.",
|
||||
mlog.String("path", profileImagePath),
|
||||
mlog.Err(errProfileImageExists),
|
||||
@@ -1876,7 +1851,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
|
||||
if errRemoveDirectory != nil {
|
||||
fileHandlingErrorsFound = true
|
||||
c.Logger().Warn(
|
||||
rctx.Logger().Warn(
|
||||
"Unable to remove profile image directory",
|
||||
mlog.String("path", profileImageDirectory),
|
||||
mlog.Err(errRemoveDirectory),
|
||||
@@ -1884,11 +1859,11 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := a.Srv().Store().FileInfo().PermanentDeleteByUser(c, user.Id); err != nil {
|
||||
if _, err := a.Srv().Store().FileInfo().PermanentDeleteByUser(rctx, user.Id); err != nil {
|
||||
return model.NewAppError("PermanentDeleteUser", "app.file_info.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().User().PermanentDelete(c, user.Id); err != nil {
|
||||
if err := a.Srv().Store().User().PermanentDelete(rctx, user.Id); err != nil {
|
||||
return model.NewAppError("PermanentDeleteUser", "app.user.permanent_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
@@ -1896,7 +1871,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
return model.NewAppError("PermanentDeleteUser", "app.audit.permanent_delete_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Team().RemoveAllMembersByUser(c, user.Id); err != nil {
|
||||
if err := a.Srv().Store().Team().RemoveAllMembersByUser(rctx, user.Id); err != nil {
|
||||
return model.NewAppError("PermanentDeleteUser", "app.team.remove_member.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
@@ -1906,7 +1881,7 @@ func (a *App) PermanentDeleteUser(c request.CTX, user *model.User) *model.AppErr
|
||||
return model.NewAppError("PermanentDeleteUser", "app.file_info.permanent_delete_by_user.app_error", nil, "Couldn't delete profile image of the user.", http.StatusAccepted)
|
||||
}
|
||||
|
||||
c.Logger().Warn("Permanently deleted account", mlog.String("user_email", user.Email), mlog.String("user_id", user.Id))
|
||||
rctx.Logger().Warn("Permanently deleted account", mlog.String("user_email", user.Email), mlog.String("user_id", user.Id))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4060,6 +4060,24 @@ func (s *OpenTracingLayerFileInfoStore) PermanentDeleteByUser(ctx request.CTX, u
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "FileInfoStore.PermanentDeleteForPost")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
err := s.FileInfoStore.PermanentDeleteForPost(rctx, postID)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerFileInfoStore) Save(ctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "FileInfoStore.Save")
|
||||
@@ -6874,6 +6892,24 @@ func (s *OpenTracingLayerPostStore) OverwriteMultiple(posts []*model.Post) ([]*m
|
||||
return result, resultVar1, err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerPostStore) PermanentDelete(rctx request.CTX, postID string) error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.PermanentDelete")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
err := s.PostStore.PermanentDelete(rctx, postID)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.PermanentDeleteBatch")
|
||||
|
||||
@@ -4559,6 +4559,27 @@ func (s *RetryLayerFileInfoStore) PermanentDeleteByUser(ctx request.CTX, userID
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.FileInfoStore.PermanentDeleteForPost(rctx, postID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerFileInfoStore) Save(ctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
|
||||
|
||||
tries := 0
|
||||
@@ -7796,6 +7817,27 @@ func (s *RetryLayerPostStore) OverwriteMultiple(posts []*model.Post) ([]*model.P
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) PermanentDelete(rctx request.CTX, postID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.PostStore.PermanentDelete(rctx, postID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
|
||||
|
||||
tries := 0
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package searchlayer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
@@ -74,6 +76,7 @@ func (s SearchFileInfoStore) deleteFileIndexForUser(rctx request.CTX, userID str
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // Temporarily unused until the post_id is indexed with the file
|
||||
func (s SearchFileInfoStore) deleteFileIndexForPost(rctx request.CTX, postID string) {
|
||||
for _, engine := range s.rootStore.searchEngine.GetActiveEngines() {
|
||||
if engine.IsIndexingEnabled() {
|
||||
@@ -133,14 +136,36 @@ func (s SearchFileInfoStore) AttachToPost(rctx request.CTX, fileId, postId, chan
|
||||
return err
|
||||
}
|
||||
|
||||
func (s SearchFileInfoStore) DeleteForPost(rctx request.CTX, postId string) (string, error) {
|
||||
result, err := s.FileInfoStore.DeleteForPost(rctx, postId)
|
||||
func (s SearchFileInfoStore) DeleteForPost(rctx request.CTX, postID string) (string, error) {
|
||||
// temporary workaround because deleteFileIndexForPost is not working due to the post_id not being indexed with the file
|
||||
files, err := s.FileInfoStore.GetForPost(postID, false, true, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get files for post %s: %w", postID, err)
|
||||
}
|
||||
result, err := s.FileInfoStore.DeleteForPost(rctx, postID)
|
||||
if err == nil {
|
||||
s.deleteFileIndexForPost(rctx, postId)
|
||||
for _, file := range files {
|
||||
s.deleteFileIndex(rctx, file.Id)
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s SearchFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
// temporary workaround because deleteFileIndexForPost is not working due to the post_id not being indexed with the file
|
||||
files, err := s.FileInfoStore.GetForPost(postID, false, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.FileInfoStore.PermanentDeleteForPost(rctx, postID)
|
||||
if err == nil {
|
||||
for _, file := range files {
|
||||
s.deleteFileIndex(rctx, file.Id)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s SearchFileInfoStore) PermanentDelete(rctx request.CTX, fileId string) error {
|
||||
err := s.FileInfoStore.PermanentDelete(rctx, fileId)
|
||||
if err == nil {
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package searchlayer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
@@ -107,19 +105,29 @@ func (s SearchPostStore) Save(rctx request.CTX, post *model.Post) (*model.Post,
|
||||
|
||||
func (s SearchPostStore) Delete(rctx request.CTX, postId string, date int64, deletedByID string) error {
|
||||
err := s.PostStore.Delete(rctx, postId, date, deletedByID)
|
||||
|
||||
if err == nil {
|
||||
opts := model.GetPostsOptions{
|
||||
SkipFetchThreads: true,
|
||||
}
|
||||
postList, err2 := s.PostStore.Get(context.Background(), postId, opts, "", map[string]bool{})
|
||||
if postList != nil && len(postList.Order) > 0 {
|
||||
if err2 != nil {
|
||||
s.deletePostIndex(rctx, postList.Posts[postList.Order[0]])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
post, err := s.PostStore.GetSingle(rctx, postId, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.deletePostIndex(rctx, post)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SearchPostStore) PermanentDelete(rctx request.CTX, postID string) error {
|
||||
// Get full post struct for later
|
||||
post, err := s.PostStore.GetSingle(rctx, postID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.PostStore.PermanentDelete(rctx, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.deletePostIndex(rctx, post)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SearchPostStore) PermanentDeleteByUser(rctx request.CTX, userID string) error {
|
||||
|
||||
@@ -183,6 +183,11 @@ var searchFileInfoStoreTests = []searchTest{
|
||||
Fn: testFileInfoSearchEmailsWithoutQuotes,
|
||||
Tags: []string{EngineElasticSearch},
|
||||
},
|
||||
{
|
||||
Name: "Should be removed from search index when deleted",
|
||||
Fn: testSearchFileDeletedPost,
|
||||
Tags: []string{EngineAll},
|
||||
},
|
||||
{
|
||||
Name: "Should not search files not attached to a post",
|
||||
Fn: testFileInfoSearchNoResultForPostlessFileInfos,
|
||||
@@ -1656,6 +1661,42 @@ func testFileInfoSearchEmailsWithoutQuotes(t *testing.T, th *SearchTestHelper) {
|
||||
th.checkFileInfoInSearchResults(t, p1.Id, results.FileInfos)
|
||||
}
|
||||
|
||||
func testSearchFileDeletedPost(t *testing.T, th *SearchTestHelper) {
|
||||
t.Run("Should not return file info for soft deleted post", func(t *testing.T) {
|
||||
post, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "deletedmessage", "", model.PostTypeDefault, 0, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "deletedmessage", "deletedmessage", "jpg", "image/jpeg", 0, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.Store.FileInfo().DeleteForPost(th.Context, post.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
params := &model.SearchParams{Terms: "deletedmessage"}
|
||||
results, err := th.Store.FileInfo().Search(th.Context, []*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, results.FileInfos, 0)
|
||||
})
|
||||
|
||||
t.Run("Should not return file info for hard deleted post", func(t *testing.T) {
|
||||
post, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "deletedmessage", "", model.PostTypeDefault, 0, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "deletedmessage", "deletedmessage", "jpg", "image/jpeg", 0, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = th.Store.FileInfo().PermanentDeleteForPost(th.Context, post.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
params := &model.SearchParams{Terms: "deletedmessage"}
|
||||
results, err := th.Store.FileInfo().Search(th.Context, []*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, results.FileInfos, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func testFileInfoSearchNoResultForPostlessFileInfos(t *testing.T, th *SearchTestHelper) {
|
||||
_, err := th.createFileInfo(th.User.Id, "", th.ChannelBasic.Id, "message test@test.com", "message test@test.com", "jpg", "image/jpeg", 0, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -275,6 +275,11 @@ var searchPostStoreTests = []searchTest{
|
||||
Fn: testSearchAcrossTeams,
|
||||
Tags: []string{EngineAll},
|
||||
},
|
||||
{
|
||||
Name: "Should be removed from search index when deleted",
|
||||
Fn: testSearchPostDeleted,
|
||||
Tags: []string{EngineAll},
|
||||
},
|
||||
}
|
||||
|
||||
func TestSearchPostStore(t *testing.T, s store.Store, testEngine *SearchTestEngine) {
|
||||
@@ -1955,3 +1960,31 @@ func testSearchAcrossTeams(t *testing.T, th *SearchTestHelper) {
|
||||
|
||||
require.Len(t, results.Posts, 2)
|
||||
}
|
||||
|
||||
func testSearchPostDeleted(t *testing.T, th *SearchTestHelper) {
|
||||
t.Run("Search for soft deleted post", func(t *testing.T) {
|
||||
p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message to delete", "", model.PostTypeDefault, 0, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = th.Store.Post().Delete(th.Context, p1.Id, p1.UpdateAt, th.User.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
params := &model.SearchParams{Terms: "message to delete"}
|
||||
results, err := th.Store.Post().SearchPostsForUser(th.Context, []*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Posts, 0)
|
||||
})
|
||||
|
||||
t.Run("Search for hard deleted post", func(t *testing.T) {
|
||||
p2, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "message to delete", "", model.PostTypeDefault, 0, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = th.Store.Post().PermanentDelete(th.Context, p2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
params := &model.SearchParams{Terms: "message to delete"}
|
||||
results, err := th.Store.Post().SearchPostsForUser(th.Context, []*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Posts, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -455,6 +455,13 @@ func (fs SqlFileInfoStore) DeleteForPost(rctx request.CTX, postId string) (strin
|
||||
return postId, nil
|
||||
}
|
||||
|
||||
func (fs SqlFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
if _, err := fs.GetMasterX().Exec(`DELETE FROM FileInfo WHERE PostId = ?`, postID); err != nil {
|
||||
return errors.Wrapf(err, "failed to delete FileInfo with PostId=%s", postID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs SqlFileInfoStore) PermanentDelete(rctx request.CTX, fileId string) error {
|
||||
if _, err := fs.GetMasterX().Exec(`DELETE FROM FileInfo WHERE Id = ?`, fileId); err != nil {
|
||||
return errors.Wrapf(err, "failed to delete FileInfo with id=%s", fileId)
|
||||
|
||||
@@ -955,6 +955,10 @@ func (s *SqlPostStore) Delete(rctx request.CTX, postID string, time int64, delet
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) PermanentDelete(rctx request.CTX, postID string) (err error) {
|
||||
return s.permanentDelete([]string{postID})
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) permanentDelete(postIds []string) (err error) {
|
||||
transaction, err := s.GetMasterX().Beginx()
|
||||
if err != nil {
|
||||
|
||||
@@ -361,6 +361,7 @@ type PostStore interface {
|
||||
Get(ctx context.Context, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error)
|
||||
GetSingle(rctx request.CTX, id string, inclDeleted bool) (*model.Post, error)
|
||||
Delete(rctx request.CTX, postID string, timestamp int64, deleteByID string) error
|
||||
PermanentDelete(rctx request.CTX, postID string) error
|
||||
PermanentDeleteByUser(rctx request.CTX, userID string) error
|
||||
PermanentDeleteByChannel(rctx request.CTX, channelID string) error
|
||||
GetPosts(options model.GetPostsOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error)
|
||||
@@ -720,6 +721,7 @@ type FileInfoStore interface {
|
||||
InvalidateFileInfosForPostCache(postID string, deleted bool)
|
||||
AttachToPost(c request.CTX, fileID string, postID string, channelID, creatorID string) error
|
||||
DeleteForPost(c request.CTX, postID string) (string, error)
|
||||
PermanentDeleteForPost(rctx request.CTX, postID string) error
|
||||
PermanentDelete(c request.CTX, fileID string) error
|
||||
PermanentDeleteBatch(ctx request.CTX, endTime int64, limit int64) (int64, error)
|
||||
PermanentDeleteByUser(ctx request.CTX, userID string) (int64, error)
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestFileInfoStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor
|
||||
t.Run("CountAll", func(t *testing.T) { testFileInfoStoreCountAll(t, rctx, ss) })
|
||||
t.Run("GetStorageUsage", func(t *testing.T) { testFileInfoGetStorageUsage(t, rctx, ss) })
|
||||
t.Run("GetUptoNSizeFileTime", func(t *testing.T) { testGetUptoNSizeFileTime(t, rctx, ss, s) })
|
||||
t.Run("FileInfoPermanentDeleteForPost", func(t *testing.T) { testPermanentDeleteForPost(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testFileInfoSaveGet(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
@@ -941,3 +942,28 @@ func testGetUptoNSizeFileTime(t *testing.T, rctx request.CTX, ss store.Store, s
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, f2.CreateAt, createAt)
|
||||
}
|
||||
|
||||
func testPermanentDeleteForPost(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
postId := model.NewId()
|
||||
|
||||
_, err := ss.FileInfo().Save(rctx, &model.FileInfo{
|
||||
PostId: postId,
|
||||
CreatorId: model.NewId(),
|
||||
Size: 10,
|
||||
Path: "file1.txt",
|
||||
CreateAt: utils.MillisFromTime(time.Now()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.FileInfo().PermanentDeleteForPost(rctx, postId)
|
||||
require.NoError(t, err)
|
||||
|
||||
postInfos, err := ss.FileInfo().GetForPost(
|
||||
postId,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, postInfos, 0)
|
||||
}
|
||||
|
||||
@@ -469,6 +469,24 @@ func (_m *FileInfoStore) PermanentDeleteByUser(ctx request.CTX, userID string) (
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// PermanentDeleteForPost provides a mock function with given fields: rctx, postID
|
||||
func (_m *FileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
ret := _m.Called(rctx, postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for PermanentDeleteForPost")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok {
|
||||
r0 = rf(rctx, postID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Save provides a mock function with given fields: ctx, info
|
||||
func (_m *FileInfoStore) Save(ctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
|
||||
ret := _m.Called(ctx, info)
|
||||
|
||||
@@ -1046,6 +1046,24 @@ func (_m *PostStore) OverwriteMultiple(posts []*model.Post) ([]*model.Post, int,
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// PermanentDelete provides a mock function with given fields: rctx, postID
|
||||
func (_m *PostStore) PermanentDelete(rctx request.CTX, postID string) error {
|
||||
ret := _m.Called(rctx, postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for PermanentDelete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok {
|
||||
r0 = rf(rctx, postID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// PermanentDeleteBatch provides a mock function with given fields: endTime, limit
|
||||
func (_m *PostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
|
||||
ret := _m.Called(endTime, limit)
|
||||
|
||||
@@ -3707,6 +3707,22 @@ func (s *TimerLayerFileInfoStore) PermanentDeleteByUser(ctx request.CTX, userID
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.FileInfoStore.PermanentDeleteForPost(rctx, postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("FileInfoStore.PermanentDeleteForPost", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerFileInfoStore) Save(ctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
|
||||
start := time.Now()
|
||||
|
||||
@@ -6217,6 +6233,22 @@ func (s *TimerLayerPostStore) OverwriteMultiple(posts []*model.Post) ([]*model.P
|
||||
return result, resultVar1, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) PermanentDelete(rctx request.CTX, postID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.PostStore.PermanentDelete(rctx, postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PostStore.PermanentDelete", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
||||
@@ -159,4 +159,6 @@ type Client interface {
|
||||
GetPreferenceByCategoryAndName(ctx context.Context, userId, category, preferenceName string) (*model.Preference, *model.Response, error)
|
||||
UpdatePreferences(ctx context.Context, userId string, preferences model.Preferences) (*model.Response, error)
|
||||
DeletePreferences(ctx context.Context, userId string, preferences model.Preferences) (*model.Response, error)
|
||||
PermanentDeletePost(ctx context.Context, postID string) (*model.Response, error)
|
||||
DeletePost(ctx context.Context, postId string) (*model.Response, error)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@ var PostListCmd = &cobra.Command{
|
||||
RunE: withClient(postListCmdF),
|
||||
}
|
||||
|
||||
var PostDeleteCmd = &cobra.Command{
|
||||
Use: "delete [posts]",
|
||||
Short: "Mark posts as deleted or permanently delete posts with the --permanent flag",
|
||||
Long: `This command will mark the post as deleted and remove it from the user's clients, but it does not permanently delete the post from the database. Please use the --permanent flag to permanently delete a post and its attachments from your database.`,
|
||||
Example: ` # Mark Post as deleted
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw
|
||||
|
||||
# Permanently delete a post and it's file contents from the database and filestore
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw --permanent
|
||||
|
||||
# Permanently delete multiple posts and their file contents from the database and filestore
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw 7jgcjt7tyjyyu83qz81wo84w6o --permanent`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: withClient(deletePostsCmdF),
|
||||
}
|
||||
|
||||
const (
|
||||
ISO8601Layout = "2006-01-02T15:04:05-07:00"
|
||||
PostTimeFormat = "2006-01-02 15:04:05-07:00"
|
||||
@@ -55,9 +71,13 @@ func init() {
|
||||
PostListCmd.Flags().BoolP("follow", "f", false, "Output appended data as new messages are posted to the channel")
|
||||
PostListCmd.Flags().StringP("since", "s", "", "List messages posted after a certain time (ISO 8601)")
|
||||
|
||||
PostDeleteCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the post and a DB backup has been performed")
|
||||
PostDeleteCmd.Flags().Bool("permanent", false, "Permanently delete the post and its contents from the database")
|
||||
|
||||
PostCmd.AddCommand(
|
||||
PostCreateCmd,
|
||||
PostListCmd,
|
||||
PostDeleteCmd,
|
||||
)
|
||||
|
||||
RootCmd.AddCommand(PostCmd)
|
||||
@@ -217,3 +237,42 @@ func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
return multiErr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func deletePostsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
|
||||
permanent, err := cmd.Flags().GetBool("permanent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
confirmFlag, _ := cmd.Flags().GetBool("confirm")
|
||||
if !confirmFlag && permanent {
|
||||
if err = getConfirmation("Are you sure you want to delete the posts specified?", true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var result *multierror.Error
|
||||
var deleteFunc func(ctx context.Context, postID string) (*model.Response, error)
|
||||
|
||||
if permanent {
|
||||
deleteFunc = c.PermanentDeletePost
|
||||
} else {
|
||||
deleteFunc = c.DeletePost
|
||||
}
|
||||
|
||||
for _, postID := range args {
|
||||
isValidId := model.IsValidId(postID)
|
||||
if !isValidId {
|
||||
printer.PrintError(fmt.Sprintf("Invalid postID: %s", postID))
|
||||
result = multierror.Append(result, err)
|
||||
continue
|
||||
}
|
||||
if _, err := deleteFunc(context.TODO(), postID); err != nil {
|
||||
printer.PrintError(fmt.Sprintf("Error deleting post: %s. Error: %s", postID, err.Error()))
|
||||
result = multierror.Append(result, err)
|
||||
continue
|
||||
}
|
||||
printer.Print(fmt.Sprintf("%s successfully deleted", postID))
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
@@ -257,3 +258,122 @@ func (s *MmctlUnitTestSuite) TestPostListCmdF() {
|
||||
s.Len(printer.GetErrorLines(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MmctlUnitTestSuite) TestDeletePostsCmdF() {
|
||||
postID1 := "ux9bxc1b8bf1zdoj1tfu14836e"
|
||||
postID2 := "ux9bxc1b8bf1zdoj1tfu14836f"
|
||||
|
||||
s.Run("invalid post id", func() {
|
||||
id := "invalid-id"
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", false, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{id})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Equal("Invalid postID: invalid-id", printer.GetErrorLines()[0])
|
||||
})
|
||||
|
||||
s.Run("successfully permanently delete one post", func() {
|
||||
printer.Clean()
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID1).
|
||||
Return(&model.Response{StatusCode: http.StatusOK}, nil).
|
||||
Times(1)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", true, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{postID1})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
|
||||
})
|
||||
|
||||
s.Run("successfully soft delete one post", func() {
|
||||
printer.Clean()
|
||||
s.client.
|
||||
EXPECT().
|
||||
DeletePost(context.TODO(), postID1).
|
||||
Return(&model.Response{StatusCode: http.StatusOK}, nil).
|
||||
Times(1)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", false, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{postID1})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
|
||||
})
|
||||
|
||||
s.Run("successfully delete multiple posts", func() {
|
||||
printer.Clean()
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID1).
|
||||
Return(&model.Response{StatusCode: http.StatusOK}, nil).
|
||||
Times(1)
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID2).
|
||||
Return(&model.Response{StatusCode: http.StatusOK}, nil).
|
||||
Times(1)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", true, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{postID1, postID2})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
|
||||
s.Require().Equal(postID2+" successfully deleted", printer.GetLines()[1])
|
||||
})
|
||||
|
||||
s.Run("PermanentDeletePost api request returns an error", func() {
|
||||
printer.Clean()
|
||||
|
||||
mockError := errors.New("an error occurred on deleting a post")
|
||||
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID1).
|
||||
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
|
||||
Times(1)
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", true, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{postID1})
|
||||
s.Require().ErrorContains(err, "an error occurred on deleting a post")
|
||||
s.Require().Len(printer.GetErrorLines(), 1)
|
||||
s.Require().Equal("Error deleting post: "+postID1+". Error: an error occurred on deleting a post",
|
||||
printer.GetErrorLines()[0])
|
||||
})
|
||||
|
||||
s.Run("Delete multiple posts but one fails with an error", func() {
|
||||
printer.Clean()
|
||||
mockError := errors.New("an error occurred on deleting a post")
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID1).
|
||||
Return(&model.Response{StatusCode: http.StatusOK}, nil).
|
||||
Times(1)
|
||||
s.client.
|
||||
EXPECT().
|
||||
PermanentDeletePost(context.TODO(), postID2).
|
||||
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
|
||||
Times(1)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("confirm", true, "")
|
||||
cmd.Flags().Bool("permanent", true, "")
|
||||
|
||||
err := deletePostsCmdF(s.client, cmd, []string{postID1, postID2})
|
||||
s.Require().ErrorContains(err, "an error occurred on deleting a post")
|
||||
s.Require().Len(printer.GetLines(), 1)
|
||||
s.Require().Len(printer.GetErrorLines(), 1)
|
||||
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
|
||||
s.Require().Equal("Error deleting post: "+postID2+". Error: an error occurred on deleting a post",
|
||||
printer.GetErrorLines()[0])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,5 +38,6 @@ SEE ALSO
|
||||
|
||||
* `mmctl <mmctl.rst>`_ - Remote client for the Open Source, self-hosted Slack-alternative
|
||||
* `mmctl post create <mmctl_post_create.rst>`_ - Create a post
|
||||
* `mmctl post delete <mmctl_post_delete.rst>`_ - Mark posts as deleted or permanently delete posts with the --permanent flag
|
||||
* `mmctl post list <mmctl_post_list.rst>`_ - List posts for a channel
|
||||
|
||||
|
||||
60
server/cmd/mmctl/docs/mmctl_post_delete.rst
Обычный файл
60
server/cmd/mmctl/docs/mmctl_post_delete.rst
Обычный файл
@@ -0,0 +1,60 @@
|
||||
.. _mmctl_post_delete:
|
||||
|
||||
mmctl post delete
|
||||
-----------------
|
||||
|
||||
Mark posts as deleted or permanently delete posts with the --permanent flag
|
||||
|
||||
Synopsis
|
||||
~~~~~~~~
|
||||
|
||||
|
||||
This command will mark the post as deleted and remove it from the user's clients, but it does not permanently delete the post from the database. Please use the --permanent flag to permanently delete a post and its attachments from your database.
|
||||
|
||||
::
|
||||
|
||||
mmctl post delete [posts] [flags]
|
||||
|
||||
Examples
|
||||
~~~~~~~~
|
||||
|
||||
::
|
||||
|
||||
# Mark Post as deleted
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw
|
||||
|
||||
# Permanently delete a post and it's file contents from the database and filestore
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw --permanent
|
||||
|
||||
# Permanently delete multiple posts and their file contents from the database and filestore
|
||||
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw 7jgcjt7tyjyyu83qz81wo84w6o --permanent
|
||||
|
||||
Options
|
||||
~~~~~~~
|
||||
|
||||
::
|
||||
|
||||
--confirm Confirm you really want to delete the post and a DB backup has been performed
|
||||
-h, --help help for delete
|
||||
--permanent Permanently delete the post and its contents from the database
|
||||
|
||||
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 post <mmctl_post.rst>`_ - Management of posts
|
||||
|
||||
@@ -417,6 +417,21 @@ func (mr *MockClientMockRecorder) DeleteOutgoingWebhook(arg0, arg1 interface{})
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOutgoingWebhook", reflect.TypeOf((*MockClient)(nil).DeleteOutgoingWebhook), arg0, arg1)
|
||||
}
|
||||
|
||||
// DeletePost mocks base method.
|
||||
func (m *MockClient) DeletePost(arg0 context.Context, arg1 string) (*model.Response, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeletePost", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.Response)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeletePost indicates an expected call of DeletePost.
|
||||
func (mr *MockClientMockRecorder) DeletePost(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePost", reflect.TypeOf((*MockClient)(nil).DeletePost), arg0, arg1)
|
||||
}
|
||||
|
||||
// DeletePreferences mocks base method.
|
||||
func (m *MockClient) DeletePreferences(arg0 context.Context, arg1 string, arg2 model.Preferences) (*model.Response, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1750,6 +1765,21 @@ func (mr *MockClientMockRecorder) PermanentDeleteChannel(arg0, arg1 interface{})
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteChannel", reflect.TypeOf((*MockClient)(nil).PermanentDeleteChannel), arg0, arg1)
|
||||
}
|
||||
|
||||
// PermanentDeletePost mocks base method.
|
||||
func (m *MockClient) PermanentDeletePost(arg0 context.Context, arg1 string) (*model.Response, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PermanentDeletePost", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.Response)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// PermanentDeletePost indicates an expected call of PermanentDeletePost.
|
||||
func (mr *MockClientMockRecorder) PermanentDeletePost(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeletePost", reflect.TypeOf((*MockClient)(nil).PermanentDeletePost), arg0, arg1)
|
||||
}
|
||||
|
||||
// PermanentDeleteTeam mocks base method.
|
||||
func (m *MockClient) PermanentDeleteTeam(arg0 context.Context, arg1 string) (*model.Response, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -2516,6 +2516,10 @@
|
||||
"id": "api.post.delete_post.can_not_delete_post_in_deleted.error",
|
||||
"translation": "Can not delete a post in a deleted channel."
|
||||
},
|
||||
{
|
||||
"id": "api.post.delete_post.not_enabled.app_error",
|
||||
"translation": "Cannot delete post, ServiceSettings.EnableAPIPostDeletion is not enabled."
|
||||
},
|
||||
{
|
||||
"id": "api.post.disabled_all",
|
||||
"translation": "@all has been disabled because the channel has more than {{.Users}} users."
|
||||
@@ -5082,6 +5086,10 @@
|
||||
"id": "app.file_info.get.gif.app_error",
|
||||
"translation": "Could not decode gif."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.get_by_post_id.app_error",
|
||||
"translation": "Failed to find files for post."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.get_for_post.app_error",
|
||||
"translation": "Unable to get the file info for the post."
|
||||
@@ -5094,6 +5102,10 @@
|
||||
"id": "app.file_info.permanent_delete_by_user.app_error",
|
||||
"translation": "Unable to delete attachments of the user."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.permanent_delete_for_post.app_error",
|
||||
"translation": "Failed to permanently delete file for post."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.save.app_error",
|
||||
"translation": "Unable to save the file info."
|
||||
@@ -6226,6 +6238,10 @@
|
||||
"id": "app.post.permanent_delete_by_user.app_error",
|
||||
"translation": "Unable to select the posts to delete for the user."
|
||||
},
|
||||
{
|
||||
"id": "app.post.permanent_delete_post.error",
|
||||
"translation": "Failed to permanently delete post."
|
||||
},
|
||||
{
|
||||
"id": "app.post.save.app_error",
|
||||
"translation": "Unable to save the Post."
|
||||
|
||||
@@ -519,6 +519,7 @@ func (ts *TelemetryService) trackConfig() {
|
||||
"enable_api_team_deletion": *cfg.ServiceSettings.EnableAPITeamDeletion,
|
||||
"enable_api_trigger_admin_notification": *cfg.ServiceSettings.EnableAPITriggerAdminNotifications,
|
||||
"enable_api_user_deletion": *cfg.ServiceSettings.EnableAPIUserDeletion,
|
||||
"enable_api_post_deletion": *cfg.ServiceSettings.EnableAPIPostDeletion,
|
||||
"enable_api_channel_deletion": *cfg.ServiceSettings.EnableAPIChannelDeletion,
|
||||
"experimental_enable_hardened_mode": *cfg.ServiceSettings.ExperimentalEnableHardenedMode,
|
||||
"experimental_strict_csrf_enforcement": *cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement,
|
||||
|
||||
@@ -4021,6 +4021,16 @@ func (c *Client4) DeletePost(ctx context.Context, postId string) (*Response, err
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// PermanentDeletePost permanently deletes a post and its files from the provided post id string.
|
||||
func (c *Client4) PermanentDeletePost(ctx context.Context, postId string) (*Response, error) {
|
||||
r, err := c.DoAPIDelete(ctx, c.postRoute(postId)+"?permanent="+c.boolString(true))
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// GetPostThread gets a post with all the other posts in the same thread.
|
||||
func (c *Client4) GetPostThread(ctx context.Context, postId string, etag string, collapsedThreads bool) (*PostList, *Response, error) {
|
||||
url := c.postRoute(postId) + "/thread"
|
||||
|
||||
@@ -387,6 +387,7 @@ type ServiceSettings struct {
|
||||
EnableAPITeamDeletion *bool
|
||||
EnableAPITriggerAdminNotifications *bool
|
||||
EnableAPIUserDeletion *bool
|
||||
EnableAPIPostDeletion *bool
|
||||
EnableDesktopLandingPage *bool
|
||||
ExperimentalEnableHardenedMode *bool `access:"experimental_features"`
|
||||
ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
|
||||
@@ -806,6 +807,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
|
||||
s.EnableAPIUserDeletion = NewPointer(false)
|
||||
}
|
||||
|
||||
if s.EnableAPIPostDeletion == nil {
|
||||
s.EnableAPIPostDeletion = NewBool(false)
|
||||
}
|
||||
|
||||
if s.EnableAPIChannelDeletion == nil {
|
||||
s.EnableAPIChannelDeletion = NewPointer(false)
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user