浏览代码

event action: add update modtime to fs rename

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 年之前
父节点
当前提交
81433e00d1

+ 8 - 8
go.mod

@@ -4,7 +4,7 @@ go 1.22.2
 
 require (
 	cloud.google.com/go/storage v1.43.0
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0
 	github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
 	github.com/alexedwards/argon2id v1.0.0
@@ -70,7 +70,7 @@ require (
 	golang.org/x/crypto v0.26.0
 	golang.org/x/net v0.28.0
 	golang.org/x/oauth2 v0.22.0
-	golang.org/x/sys v0.23.0
+	golang.org/x/sys v0.24.0
 	golang.org/x/term v0.23.0
 	golang.org/x/time v0.6.0
 	google.golang.org/api v0.191.0
@@ -80,9 +80,9 @@ require (
 require (
 	cloud.google.com/go v0.115.0 // indirect
 	cloud.google.com/go/auth v0.8.0 // indirect
-	cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
 	cloud.google.com/go/compute/metadata v0.5.0 // indirect
-	cloud.google.com/go/iam v1.1.12 // indirect
+	cloud.google.com/go/iam v1.1.13 // indirect
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/ajg/form v1.5.1 // indirect
@@ -167,15 +167,15 @@ require (
 	go.opentelemetry.io/otel/metric v1.28.0 // indirect
 	go.opentelemetry.io/otel/trace v1.28.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+	golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
 	golang.org/x/mod v0.20.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/tools v0.24.0 // indirect
 	golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
-	google.golang.org/genproto v0.0.0-20240805194559-2c9e96a0b5d4 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240805194559-2c9e96a0b5d4 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240805194559-2c9e96a0b5d4 // indirect
+	google.golang.org/genproto v0.0.0-20240808171019-573a1156607a // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a // indirect
 	google.golang.org/grpc v1.65.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 16 - 16
go.sum

@@ -3,12 +3,12 @@ cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
 cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
 cloud.google.com/go/auth v0.8.0 h1:y8jUJLl/Fg+qNBWxP/Hox2ezJvjkrPb952PC1p0G6A4=
 cloud.google.com/go/auth v0.8.0/go.mod h1:qGVp/Y3kDRSDZ5gFD/XPUfYQ9xW1iI7q8RIRoCyBbJc=
-cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
-cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
+cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
+cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
 cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
 cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
-cloud.google.com/go/iam v1.1.12 h1:JixGLimRrNGcxvJEQ8+clfLxPlbeZA6MuRJ+qJNQ5Xw=
-cloud.google.com/go/iam v1.1.12/go.mod h1:9LDX8J7dN5YRyzVHxwQzrQs9opFFqn0Mxs9nAeB+Hhg=
+cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
+cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
 cloud.google.com/go/kms v1.18.4 h1:dYN3OCsQ6wJLLtOnI8DGUwQ5shMusXsWCCC+s09ATsk=
 cloud.google.com/go/kms v1.18.4/go.mod h1:SG1bgQ3UWW6/KdPo9uuJnzELXY5YTTMJtDYvajiQ22g=
 cloud.google.com/go/longrunning v0.5.11 h1:Havn1kGjz3whCfoD8dxMLP73Ph5w+ODyZB9RUsDxtGk=
@@ -18,8 +18,8 @@ cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502Jw
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 h1:GJHeeA2N7xrG3q30L2UXDyuWRzDM900/65j70wcM4Ww=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
@@ -419,8 +419,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
 gocloud.dev v0.38.0 h1:SpxfaOc/Fp4PeO8ui7wRcCZV0EgXZ+IWcVSLn6ZMSw0=
 gocloud.dev v0.38.0/go.mod h1:3XjKvd2E5iVNu/xFImRzjN0d/fkNHe4s0RiKidpEUMQ=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
+golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -479,8 +479,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
-golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -525,12 +525,12 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20240805194559-2c9e96a0b5d4 h1:g+rQ3aqOyXK/0qwnC5TGUXnyIeipstP5SsniB9uPJ2c=
-google.golang.org/genproto v0.0.0-20240805194559-2c9e96a0b5d4/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
-google.golang.org/genproto/googleapis/api v0.0.0-20240805194559-2c9e96a0b5d4 h1:ABEBT/sZ7We8zd7A5f3KO6zMQe+s3901H7l8Whhijt0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240805194559-2c9e96a0b5d4/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240805194559-2c9e96a0b5d4 h1:OsSGQeIIsyOEOimVxLEIL4rwGcnrjOydQaiA2bOnZUM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240805194559-2c9e96a0b5d4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+google.golang.org/genproto v0.0.0-20240808171019-573a1156607a h1:3JVv3Ujh+kGiajpSqHWnbWPuu0nQqMZ3hASNDDF9974=
+google.golang.org/genproto v0.0.0-20240808171019-573a1156607a/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
+google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a h1:KyUe15n7B1YCu+kMmPtlXxgkLQbp+Dw0tCRZf9Sd+CE=
+google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a h1:EKiZZXueP9/T68B8Nl0GAx9cjbQnCId0yP3qPMgaaHs=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=

+ 5 - 3
internal/common/connection.go

@@ -787,10 +787,12 @@ func (c *BaseConnection) Copy(virtualSourcePath, virtualTargetPath string) error
 
 // Rename renames (moves) virtualSourcePath to virtualTargetPath
 func (c *BaseConnection) Rename(virtualSourcePath, virtualTargetPath string) error {
-	return c.renameInternal(virtualSourcePath, virtualTargetPath, false)
+	return c.renameInternal(virtualSourcePath, virtualTargetPath, false, vfs.CheckParentDir)
 }
 
-func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath string, checkParentDestination bool) error {
+func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath string,
+	checkParentDestination bool, checks int,
+) error {
 	if virtualSourcePath == virtualTargetPath {
 		return fmt.Errorf("the rename source and target cannot be the same: %w", c.GetOpUnsupportedError())
 	}
@@ -844,7 +846,7 @@ func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath str
 	defer close(done)
 	go keepConnectionAlive(c, done, 2*time.Minute)
 
-	files, size, err := fsDst.Rename(fsSourcePath, fsTargetPath)
+	files, size, err := fsDst.Rename(fsSourcePath, fsTargetPath, checks)
 	if err != nil {
 		c.Log(logger.LevelError, "failed to rename %q -> %q: %+v", fsSourcePath, fsTargetPath, err)
 		return c.GetFsError(fsSrc, err)

+ 7 - 3
internal/common/eventmanager.go

@@ -1753,7 +1753,7 @@ func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer,
 	return nil
 }
 
-func executeRenameFsActionForUser(renames []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeRenameFsActionForUser(renames []dataprovider.RenameConfig, replacer *strings.Replacer,
 	user dataprovider.User,
 ) error {
 	user, err := getUserForEventAction(user)
@@ -1770,7 +1770,11 @@ func executeRenameFsActionForUser(renames []dataprovider.KeyValue, replacer *str
 	for _, item := range renames {
 		source := util.CleanPath(replaceWithReplacer(item.Key, replacer))
 		target := util.CleanPath(replaceWithReplacer(item.Value, replacer))
-		if err = conn.renameInternal(source, target, true); err != nil {
+		checks := 0
+		if item.UpdateModTime {
+			checks += vfs.CheckUpdateModTime
+		}
+		if err = conn.renameInternal(source, target, true, checks); err != nil {
 			return fmt.Errorf("unable to rename %q->%q, user %q: %w", source, target, user.Username, err)
 		}
 		eventManagerLog(logger.LevelDebug, "rename %q->%q ok, user %q", source, target, user.Username)
@@ -1832,7 +1836,7 @@ func executeExistFsActionForUser(exist []string, replacer *strings.Replacer,
 	return nil
 }
 
-func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeRenameFsRuleAction(renames []dataprovider.RenameConfig, replacer *strings.Replacer,
 	conditions dataprovider.ConditionOptions, params *EventParams,
 ) error {
 	users, err := params.getUsers()

+ 15 - 9
internal/common/eventmanager_test.go

@@ -1235,10 +1235,12 @@ func TestEventRuleActions(t *testing.T) {
 	action.Options = dataprovider.BaseEventActionOptions{
 		FsConfig: dataprovider.EventActionFilesystemConfig{
 			Type: dataprovider.FilesystemActionRename,
-			Renames: []dataprovider.KeyValue{
+			Renames: []dataprovider.RenameConfig{
 				{
-					Key:   "/source",
-					Value: "/target",
+					KeyValue: dataprovider.KeyValue{
+						Key:   "/source",
+						Value: "/target",
+					},
 				},
 			},
 		},
@@ -1778,10 +1780,12 @@ func TestFilesystemActionErrors(t *testing.T) {
 	assert.NoError(t, err)
 	err = dataprovider.AddUser(&user, "", "", "")
 	assert.NoError(t, err)
-	err = executeRenameFsActionForUser([]dataprovider.KeyValue{
+	err = executeRenameFsActionForUser([]dataprovider.RenameConfig{
 		{
-			Key:   "/p1",
-			Value: "/p1",
+			KeyValue: dataprovider.KeyValue{
+				Key:   "/p1",
+				Value: "/p1",
+			},
 		},
 	}, testReplacer, user)
 	if assert.Error(t, err) {
@@ -1792,10 +1796,12 @@ func TestFilesystemActionErrors(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionRename,
-				Renames: []dataprovider.KeyValue{
+				Renames: []dataprovider.RenameConfig{
 					{
-						Key:   "/p2",
-						Value: "/p2",
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/p2",
+							Value: "/p2",
+						},
 					},
 				},
 			},

+ 84 - 53
internal/common/protocol_test.go

@@ -4301,10 +4301,12 @@ func TestEventRuleFsActions(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionRename,
-				Renames: []dataprovider.KeyValue{
+				Renames: []dataprovider.RenameConfig{
 					{
-						Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
-						Value: "/{{ObjectName}}_renamed",
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/{{VirtualDirPath}}/{{ObjectName}}",
+							Value: "/{{ObjectName}}_renamed",
+						},
 					},
 				},
 			},
@@ -4711,10 +4713,13 @@ func TestEventRulePreDelete(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionRename,
-				Renames: []dataprovider.KeyValue{
+				Renames: []dataprovider.RenameConfig{
 					{
-						Key:   "/{{VirtualPath}}",
-						Value: fmt.Sprintf("/%s/{{VirtualPath}}", movePath),
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/{{VirtualPath}}",
+							Value: fmt.Sprintf("/%s/{{VirtualPath}}", movePath),
+						},
+						UpdateModTime: true,
 					},
 				},
 			},
@@ -4768,59 +4773,83 @@ func TestEventRulePreDelete(t *testing.T) {
 			QuotaFiles:  1000,
 		},
 	}
-	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	u = getTestSFTPUser()
+	u.QuotaFiles = 1000
+	sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	conn, client, err := getSftpClient(user)
-	if assert.NoError(t, err) {
-		defer conn.Close()
-		defer client.Close()
 
-		testDir := "sub dir"
-		err = client.MkdirAll(testDir)
-		assert.NoError(t, err)
-		err = writeSFTPFile(testFileName, 100, client)
-		assert.NoError(t, err)
-		err = writeSFTPFile(path.Join(testDir, testFileName), 100, client)
-		assert.NoError(t, err)
-		err = client.Remove(testFileName)
-		assert.NoError(t, err)
-		err = client.Remove(path.Join(testDir, testFileName))
-		assert.NoError(t, err)
-		// check files
-		_, err = client.Stat(testFileName)
-		assert.ErrorIs(t, err, os.ErrNotExist)
-		_, err = client.Stat(path.Join(testDir, testFileName))
-		assert.ErrorIs(t, err, os.ErrNotExist)
-		_, err = client.Stat(path.Join("/", movePath, testFileName))
-		assert.NoError(t, err)
-		_, err = client.Stat(path.Join("/", movePath, testDir, testFileName))
-		assert.NoError(t, err)
-		// check quota
-		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
-		assert.NoError(t, err)
-		assert.Equal(t, 0, user.UsedQuotaFiles)
-		assert.Equal(t, int64(0), user.UsedQuotaSize)
-		folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
-		assert.NoError(t, err)
-		assert.Equal(t, 2, folder.UsedQuotaFiles)
-		assert.Equal(t, int64(200), folder.UsedQuotaSize)
-		// pre-delete action is not executed in movePath
-		err = client.Remove(path.Join("/", movePath, testFileName))
-		assert.NoError(t, err)
-		// check quota
-		folder, _, err = httpdtest.GetFolderByName(movePath, http.StatusOK)
-		assert.NoError(t, err)
-		assert.Equal(t, 1, folder.UsedQuotaFiles)
-		assert.Equal(t, int64(100), folder.UsedQuotaSize)
+	for _, user := range []dataprovider.User{localUser, sftpUser} {
+		conn, client, err := getSftpClient(user)
+		if assert.NoError(t, err) {
+			defer conn.Close()
+			defer client.Close()
+
+			testDir := "sub dir"
+			err = client.MkdirAll(testDir)
+			assert.NoError(t, err)
+			err = writeSFTPFile(testFileName, 100, client)
+			assert.NoError(t, err)
+			err = writeSFTPFile(path.Join(testDir, testFileName), 100, client)
+			assert.NoError(t, err)
+			modTime := time.Now().Add(-36 * time.Hour)
+			err = client.Chtimes(testFileName, modTime, modTime)
+			assert.NoError(t, err)
+			err = client.Remove(testFileName)
+			assert.NoError(t, err)
+			err = client.Remove(path.Join(testDir, testFileName))
+			assert.NoError(t, err)
+			// check files
+			_, err = client.Stat(testFileName)
+			assert.ErrorIs(t, err, os.ErrNotExist)
+			_, err = client.Stat(path.Join(testDir, testFileName))
+			assert.ErrorIs(t, err, os.ErrNotExist)
+			info, err := client.Stat(path.Join("/", movePath, testFileName))
+			assert.NoError(t, err)
+			diff := math.Abs(time.Until(info.ModTime()).Seconds())
+			assert.LessOrEqual(t, diff, float64(2))
+
+			_, err = client.Stat(path.Join("/", movePath, testDir, testFileName))
+			assert.NoError(t, err)
+			// check quota
+			user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+			assert.NoError(t, err)
+			if user.Username == localUser.Username {
+				assert.Equal(t, 0, user.UsedQuotaFiles)
+				assert.Equal(t, int64(0), user.UsedQuotaSize)
+				folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
+				assert.NoError(t, err)
+				assert.Equal(t, 2, folder.UsedQuotaFiles)
+				assert.Equal(t, int64(200), folder.UsedQuotaSize)
+			} else {
+				assert.Equal(t, 1, user.UsedQuotaFiles)
+				assert.Equal(t, int64(100), user.UsedQuotaSize)
+			}
+			// pre-delete action is not executed in movePath
+			err = client.Remove(path.Join("/", movePath, testFileName))
+			assert.NoError(t, err)
+			if user.Username == localUser.Username {
+				// check quota
+				folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
+				assert.NoError(t, err)
+				assert.Equal(t, 1, folder.UsedQuotaFiles)
+				assert.Equal(t, int64(100), folder.UsedQuotaSize)
+				err = os.RemoveAll(user.GetHomeDir())
+				assert.NoError(t, err)
+			}
+		}
 	}
 
 	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
 	assert.NoError(t, err)
 	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
 	assert.NoError(t, err)
-	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	_, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
 	assert.NoError(t, err)
-	err = os.RemoveAll(user.GetHomeDir())
+	_, err = httpdtest.RemoveUser(localUser, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(localUser.GetHomeDir())
 	assert.NoError(t, err)
 	_, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: movePath}, http.StatusOK)
 	assert.NoError(t, err)
@@ -4848,10 +4877,12 @@ func TestEventRulePreDownloadUpload(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 			FsConfig: dataprovider.EventActionFilesystemConfig{
 				Type: dataprovider.FilesystemActionRename,
-				Renames: []dataprovider.KeyValue{
+				Renames: []dataprovider.RenameConfig{
 					{
-						Key:   "/missing source",
-						Value: "/missing target",
+						KeyValue: dataprovider.KeyValue{
+							Key:   "/missing source",
+							Value: "/missing target",
+						},
 					},
 				},
 			},

+ 1 - 1
internal/common/transfer.go

@@ -394,7 +394,7 @@ func (t *BaseTransfer) Close() error {
 			t.effectiveFsPath, err)
 	} else if t.isAtomicUpload() {
 		if t.ErrTransfer == nil || Config.UploadMode&UploadModeAtomicWithResume != 0 {
-			_, _, err = t.Fs.Rename(t.effectiveFsPath, t.fsPath)
+			_, _, err = t.Fs.Rename(t.effectiveFsPath, t.fsPath, 0)
 			t.Connection.Log(logger.LevelDebug, "atomic upload completed, rename: %q -> %q, error: %v",
 				t.effectiveFsPath, t.fsPath, err)
 			// the file must be removed if it is uploaded to a path outside the home dir and cannot be renamed

+ 35 - 9
internal/dataprovider/eventrule.go

@@ -660,12 +660,21 @@ func (c *EventActionFsCompress) validate() error {
 	return nil
 }
 
+// RenameConfig defines the configuration for a filesystem rename
+type RenameConfig struct {
+	// key is the source and target the value
+	KeyValue
+	// This setting only applies to storage providers that support
+	// changing modification times.
+	UpdateModTime bool `json:"update_modtime,omitempty"`
+}
+
 // EventActionFilesystemConfig defines the configuration for filesystem actions
 type EventActionFilesystemConfig struct {
 	// Filesystem actions, see the above enum
 	Type int `json:"type,omitempty"`
-	// files/dirs to rename, key is the source and target the value
-	Renames []KeyValue `json:"renames,omitempty"`
+	// files/dirs to rename
+	Renames []RenameConfig `json:"renames,omitempty"`
 	// directories to create
 	MkDirs []string `json:"mkdirs,omitempty"`
 	// files/dirs to delete
@@ -706,9 +715,9 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
 	if len(c.Renames) == 0 {
 		return util.NewI18nError(util.NewValidationError("no path to rename specified"), util.I18nErrorPathRequired)
 	}
-	for idx, kv := range c.Renames {
-		key := strings.TrimSpace(kv.Key)
-		value := strings.TrimSpace(kv.Value)
+	for idx, cfg := range c.Renames {
+		key := strings.TrimSpace(cfg.Key)
+		value := strings.TrimSpace(cfg.Value)
 		if key == "" || value == "" {
 			return util.NewValidationError("invalid paths to rename")
 		}
@@ -726,9 +735,12 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
 				util.I18nErrorRootNotAllowed,
 			)
 		}
-		c.Renames[idx] = KeyValue{
-			Key:   key,
-			Value: value,
+		c.Renames[idx] = RenameConfig{
+			KeyValue: KeyValue{
+				Key:   key,
+				Value: value,
+			},
+			UpdateModTime: cfg.UpdateModTime,
 		}
 	}
 	return nil
@@ -892,7 +904,7 @@ func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig {
 
 	return EventActionFilesystemConfig{
 		Type:    c.Type,
-		Renames: cloneKeyValues(c.Renames),
+		Renames: cloneRenameConfigs(c.Renames),
 		MkDirs:  mkdirs,
 		Deletes: deletes,
 		Exist:   exist,
@@ -1833,6 +1845,20 @@ func (r *EventRule) RenderAsJSON(reload bool) ([]byte, error) {
 	return json.Marshal(r)
 }
 
+func cloneRenameConfigs(renames []RenameConfig) []RenameConfig {
+	res := make([]RenameConfig, 0, len(renames))
+	for _, c := range renames {
+		res = append(res, RenameConfig{
+			KeyValue: KeyValue{
+				Key:   c.Key,
+				Value: c.Value,
+			},
+			UpdateModTime: c.UpdateModTime,
+		})
+	}
+	return res
+}
+
 func cloneKeyValues(keyVals []KeyValue) []KeyValue {
 	res := make([]KeyValue, 0, len(keyVals))
 	for _, kv := range keyVals {

+ 1 - 1
internal/ftpd/handler.go

@@ -465,7 +465,7 @@ func (c *Connection) handleFTPUploadToExistingFile(fs vfs.Fs, flags int, resolve
 	}
 
 	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
-		_, _, err = fs.Rename(resolvedPath, filePath)
+		_, _, err = fs.Rename(resolvedPath, filePath, 0)
 		if err != nil {
 			c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
 				resolvedPath, filePath, err)

+ 1 - 1
internal/ftpd/internal_test.go

@@ -404,7 +404,7 @@ func (fs MockOsFs) Remove(name string, _ bool) error {
 }
 
 // Rename renames (moves) source to target
-func (fs MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
 	if fs.err != nil {
 		return -1, -1, fs.err
 	}

+ 1 - 1
internal/httpd/handler.go

@@ -176,7 +176,7 @@ func (c *Connection) getFileWriter(name string) (io.WriteCloser, error) {
 	}
 
 	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
-		_, _, err = fs.Rename(p, filePath)
+		_, _, err = fs.Rename(p, filePath, 0)
 		if err != nil {
 			c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
 				p, filePath, err)

+ 24 - 13
internal/httpd/httpd_test.go

@@ -2458,28 +2458,34 @@ func TestEventActionValidation(t *testing.T) {
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "no path to rename specified")
-	action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+	action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
 		{
-			Key:   "",
-			Value: "/adir",
+			KeyValue: dataprovider.KeyValue{
+				Key:   "",
+				Value: "/adir",
+			},
 		},
 	}
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "invalid paths to rename")
-	action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+	action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
 		{
-			Key:   "adir",
-			Value: "/adir",
+			KeyValue: dataprovider.KeyValue{
+				Key:   "adir",
+				Value: "/adir",
+			},
 		},
 	}
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "rename source and target cannot be equal")
-	action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+	action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
 		{
-			Key:   "/",
-			Value: "/dir",
+			KeyValue: dataprovider.KeyValue{
+				Key:   "/",
+				Value: "/dir",
+			},
 		},
 	}
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
@@ -23974,16 +23980,19 @@ func TestWebEventAction(t *testing.T) {
 
 	action.Options.FsConfig = dataprovider.EventActionFilesystemConfig{
 		Type: dataprovider.FilesystemActionRename,
-		Renames: []dataprovider.KeyValue{
+		Renames: []dataprovider.RenameConfig{
 			{
-				Key:   "/src",
-				Value: "/target",
+				KeyValue: dataprovider.KeyValue{
+					Key:   "/src",
+					Value: "/target",
+				},
 			},
 		},
 	}
 	form.Set("fs_action_type", fmt.Sprintf("%d", action.Options.FsConfig.Type))
 	form.Set("fs_rename[0][fs_rename_source]", action.Options.FsConfig.Renames[0].Key)
 	form.Set("fs_rename[0][fs_rename_target]", action.Options.FsConfig.Renames[0].Value)
+	form.Set("fs_rename[0][fs_rename_options][]", "1")
 	req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
 		bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
@@ -23995,7 +24004,9 @@ func TestWebEventAction(t *testing.T) {
 	actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK)
 	assert.NoError(t, err)
 	assert.Equal(t, action.Type, actionGet.Type)
-	assert.Len(t, actionGet.Options.FsConfig.Renames, 1)
+	if assert.Len(t, actionGet.Options.FsConfig.Renames, 1) {
+		assert.True(t, actionGet.Options.FsConfig.Renames[0].UpdateModTime)
+	}
 
 	action.Options.FsConfig = dataprovider.EventActionFilesystemConfig{
 		Type: dataprovider.FilesystemActionCopy,

+ 25 - 1
internal/httpd/webadmin.go

@@ -2201,6 +2201,28 @@ func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.K
 	return res
 }
 
+func getRenameConfigsFromPostFields(r *http.Request) []dataprovider.RenameConfig {
+	var res []dataprovider.RenameConfig
+	keys := r.Form["fs_rename_source"]
+	values := r.Form["fs_rename_target"]
+
+	for idx, k := range keys {
+		v := values[idx]
+		if k != "" && v != "" {
+			opts := r.Form["fs_rename_options"+strconv.Itoa(idx)]
+			res = append(res, dataprovider.RenameConfig{
+				KeyValue: dataprovider.KeyValue{
+					Key:   k,
+					Value: v,
+				},
+				UpdateModTime: slices.Contains(opts, "1"),
+			})
+		}
+	}
+
+	return res
+}
+
 func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) {
 	var res []dataprovider.FolderRetention
 	paths := r.Form["folder_retention_path"]
@@ -2310,6 +2332,8 @@ func updateRepeaterFormActionFields(r *http.Request) {
 			base, _ := strings.CutSuffix(k, "[fs_rename_source]")
 			r.Form.Add("fs_rename_source", strings.TrimSpace(r.Form.Get(k)))
 			r.Form.Add("fs_rename_target", strings.TrimSpace(r.Form.Get(base+"[fs_rename_target]")))
+			r.Form["fs_rename_options"+strconv.Itoa(len(r.Form["fs_rename_source"])-1)] =
+				r.Form[base+"[fs_rename_options][]"]
 			continue
 		}
 		if hasPrefixAndSuffix(k, "fs_copy[", "][fs_copy_source]") {
@@ -2398,7 +2422,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 		},
 		FsConfig: dataprovider.EventActionFilesystemConfig{
 			Type:    fsActionType,
-			Renames: getKeyValsFromPostFields(r, "fs_rename_source", "fs_rename_target"),
+			Renames: getRenameConfigsFromPostFields(r),
 			Deletes: getSliceFromDelimitedValues(r.Form.Get("fs_delete_paths"), ","),
 			MkDirs:  getSliceFromDelimitedValues(r.Form.Get("fs_mkdir_paths"), ","),
 			Exist:   getSliceFromDelimitedValues(r.Form.Get("fs_exist_paths"), ","),

+ 22 - 3
internal/httpdtest/httpdtest.go

@@ -2601,9 +2601,28 @@ func compareUserFilePatternsFilters(expected sdk.BaseUserFilters, actual sdk.Bas
 	return nil
 }
 
+func compareRenameConfigs(expected, actual []dataprovider.RenameConfig) error {
+	if len(expected) != len(actual) {
+		return errors.New("rename configs mismatch")
+	}
+	for _, ex := range expected {
+		found := false
+		for _, ac := range actual {
+			if ac.Key == ex.Key && ac.Value == ex.Value && ac.UpdateModTime == ex.UpdateModTime {
+				found = true
+				break
+			}
+		}
+		if !found {
+			return errors.New("rename configs mismatch")
+		}
+	}
+	return nil
+}
+
 func compareKeyValues(expected, actual []dataprovider.KeyValue) error {
 	if len(expected) != len(actual) {
-		return errors.New("kay values mismatch")
+		return errors.New("key values mismatch")
 	}
 	for _, ex := range expected {
 		found := false
@@ -2614,7 +2633,7 @@ func compareKeyValues(expected, actual []dataprovider.KeyValue) error {
 			}
 		}
 		if !found {
-			return errors.New("kay values mismatch")
+			return errors.New("key values mismatch")
 		}
 	}
 	return nil
@@ -2731,7 +2750,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF
 	if expected.Type != actual.Type {
 		return errors.New("fs type mismatch")
 	}
-	if err := compareKeyValues(expected.Renames, actual.Renames); err != nil {
+	if err := compareRenameConfigs(expected.Renames, actual.Renames); err != nil {
 		return errors.New("fs renames mismatch")
 	}
 	if err := compareKeyValues(expected.Copy, actual.Copy); err != nil {

+ 1 - 1
internal/sftpd/handler.go

@@ -457,7 +457,7 @@ func (c *Connection) handleSFTPUploadToExistingFile(fs vfs.Fs, pflags sftp.FileO
 	}
 
 	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
-		_, _, err = fs.Rename(resolvedPath, filePath)
+		_, _, err = fs.Rename(resolvedPath, filePath, 0)
 		if err != nil {
 			c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
 				resolvedPath, filePath, err)

+ 1 - 1
internal/sftpd/internal_test.go

@@ -146,7 +146,7 @@ func (fs MockOsFs) Remove(name string, _ bool) error {
 }
 
 // Rename renames (moves) source to target
-func (fs MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
 	if fs.err != nil {
 		return -1, -1, fs.err
 	}

+ 1 - 1
internal/sftpd/scp.go

@@ -330,7 +330,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 	}
 
 	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
-		_, _, err = fs.Rename(p, filePath)
+		_, _, err = fs.Rename(p, filePath, 0)
 		if err != nil {
 			c.connection.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %v",
 				p, filePath, err)

+ 41 - 20
internal/vfs/azblobfs.go

@@ -306,19 +306,21 @@ func (fs *AzureBlobFs) Create(name string, flag, checks int) (File, PipeWriter,
 }
 
 // Rename renames (moves) source to target.
-func (fs *AzureBlobFs) Rename(source, target string) (int, int64, error) {
+func (fs *AzureBlobFs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
-	_, err := fs.Stat(path.Dir(target))
-	if err != nil {
-		return -1, -1, err
+	if checks&CheckParentDir != 0 {
+		_, err := fs.Stat(path.Dir(target))
+		if err != nil {
+			return -1, -1, err
+		}
 	}
 	fi, err := fs.Stat(source)
 	if err != nil {
 		return -1, -1, err
 	}
-	return fs.renameInternal(source, target, fi, 0)
+	return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
 }
 
 // Remove removes the named file or (empty) directory.
@@ -398,7 +400,17 @@ func (fs *AzureBlobFs) Chtimes(name string, _, mtime time.Time, isUploading bool
 	if metadata == nil {
 		metadata = make(map[string]*string)
 	}
-	metadata[lastModifiedField] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+	found := false
+	for k := range metadata {
+		if strings.ToLower(k) == lastModifiedField {
+			metadata[k] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+			found = true
+			break
+		}
+	}
+	if !found {
+		metadata[lastModifiedField] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+	}
 
 	ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
 	defer cancelFn()
@@ -667,7 +679,7 @@ func (fs *AzureBlobFs) CopyFile(source, target string, srcInfo os.FileInfo) (int
 			return 0, 0, err
 		}
 	}
-	if err := fs.copyFileInternal(source, target, srcInfo); err != nil {
+	if err := fs.copyFileInternal(source, target, srcInfo, true); err != nil {
 		return 0, 0, err
 	}
 	return numFiles, sizeDiff, nil
@@ -750,13 +762,13 @@ func (fs *AzureBlobFs) setConfigDefaults() {
 	}
 }
 
-func (fs *AzureBlobFs) copyFileInternal(source, target string, srcInfo os.FileInfo) error {
+func (fs *AzureBlobFs) copyFileInternal(source, target string, srcInfo os.FileInfo, updateModTime bool) error {
 	ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxLongTimeout))
 	defer cancelFn()
 
 	srcBlob := fs.containerClient.NewBlockBlobClient(source)
 	dstBlob := fs.containerClient.NewBlockBlobClient(target)
-	resp, err := dstBlob.StartCopyFromURL(ctx, srcBlob.URL(), fs.getCopyOptions(srcInfo))
+	resp, err := dstBlob.StartCopyFromURL(ctx, srcBlob.URL(), fs.getCopyOptions(srcInfo, updateModTime))
 	if err != nil {
 		metric.AZCopyObjectCompleted(err)
 		return err
@@ -789,7 +801,9 @@ func (fs *AzureBlobFs) copyFileInternal(source, target string, srcInfo os.FileIn
 	return nil
 }
 
-func (fs *AzureBlobFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int) (int, int64, error) {
+func (fs *AzureBlobFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+	updateModTime bool,
+) (int, int64, error) {
 	var numFiles int
 	var filesSize int64
 
@@ -807,7 +821,7 @@ func (fs *AzureBlobFs) renameInternal(source, target string, srcInfo os.FileInfo
 			return numFiles, filesSize, err
 		}
 		if renameMode == 1 {
-			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
 			numFiles += files
 			filesSize += size
 			if err != nil {
@@ -815,7 +829,7 @@ func (fs *AzureBlobFs) renameInternal(source, target string, srcInfo os.FileInfo
 			}
 		}
 	} else {
-		if err := fs.copyFileInternal(source, target, srcInfo); err != nil {
+		if err := fs.copyFileInternal(source, target, srcInfo, updateModTime); err != nil {
 			return numFiles, filesSize, err
 		}
 		numFiles++
@@ -1102,20 +1116,27 @@ func (*AzureBlobFs) readFill(r io.Reader, buf []byte) (n int, err error) {
 	return n, err
 }
 
-func (fs *AzureBlobFs) getCopyOptions(srcInfo os.FileInfo) *blob.StartCopyFromURLOptions {
+func (fs *AzureBlobFs) getCopyOptions(srcInfo os.FileInfo, updateModTime bool) *blob.StartCopyFromURLOptions {
 	copyOptions := &blob.StartCopyFromURLOptions{}
 	if fs.config.AccessTier != "" {
 		copyOptions.Tier = (*blob.AccessTier)(&fs.config.AccessTier)
 	}
-	metadata := make(map[string]*string)
-	for k, v := range getMetadata(srcInfo) {
-		if v != "" {
-			metadata[k] = to.Ptr(v)
+	if updateModTime {
+		metadata := make(map[string]*string)
+		for k, v := range getMetadata(srcInfo) {
+			if v != "" {
+				if strings.ToLower(k) == lastModifiedField {
+					metadata[k] = to.Ptr("0")
+				} else {
+					metadata[k] = to.Ptr(v)
+				}
+			}
+		}
+		if len(metadata) > 0 {
+			copyOptions.Metadata = metadata
 		}
 	}
-	if len(metadata) > 0 {
-		copyOptions.Metadata = metadata
-	}
+
 	return copyOptions
 }
 

+ 19 - 10
internal/vfs/gcsfs.go

@@ -255,19 +255,21 @@ func (fs *GCSFs) Create(name string, flag, checks int) (File, PipeWriter, func()
 }
 
 // Rename renames (moves) source to target.
-func (fs *GCSFs) Rename(source, target string) (int, int64, error) {
+func (fs *GCSFs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
-	_, err := fs.Stat(path.Dir(target))
-	if err != nil {
-		return -1, -1, err
+	if checks&CheckParentDir != 0 {
+		_, err := fs.Stat(path.Dir(target))
+		if err != nil {
+			return -1, -1, err
+		}
 	}
 	fi, err := fs.getObjectStat(source)
 	if err != nil {
 		return -1, -1, err
 	}
-	return fs.renameInternal(source, target, fi, 0)
+	return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
 }
 
 // Remove removes the named file or (empty) directory.
@@ -651,7 +653,7 @@ func (fs *GCSFs) CopyFile(source, target string, srcInfo os.FileInfo) (int, int6
 		}
 		conditions = &storage.Conditions{DoesNotExist: true}
 	}
-	if err := fs.copyFileInternal(source, target, conditions, srcInfo); err != nil {
+	if err := fs.copyFileInternal(source, target, conditions, srcInfo, true); err != nil {
 		return 0, 0, err
 	}
 	return numFiles, sizeDiff, nil
@@ -753,7 +755,9 @@ func (fs *GCSFs) composeObjects(ctx context.Context, dst, partialObject *storage
 	return err
 }
 
-func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Conditions, srcInfo os.FileInfo) error {
+func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Conditions,
+	srcInfo os.FileInfo, updateModTime bool,
+) error {
 	src := fs.svc.Bucket(fs.config.Bucket).Object(source)
 	dst := fs.svc.Bucket(fs.config.Bucket).Object(target)
 	if conditions != nil {
@@ -785,6 +789,9 @@ func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Con
 		copier.ContentType = contentType
 	}
 	metadata := getMetadata(srcInfo)
+	if updateModTime && len(metadata) > 0 {
+		delete(metadata, lastModifiedField)
+	}
 	if len(metadata) > 0 {
 		copier.Metadata = metadata
 	}
@@ -793,7 +800,9 @@ func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Con
 	return err
 }
 
-func (fs *GCSFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int) (int, int64, error) {
+func (fs *GCSFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+	updateModTime bool,
+) (int, int64, error) {
 	var numFiles int
 	var filesSize int64
 
@@ -811,7 +820,7 @@ func (fs *GCSFs) renameInternal(source, target string, srcInfo os.FileInfo, recu
 			return numFiles, filesSize, err
 		}
 		if renameMode == 1 {
-			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
 			numFiles += files
 			filesSize += size
 			if err != nil {
@@ -819,7 +828,7 @@ func (fs *GCSFs) renameInternal(source, target string, srcInfo os.FileInfo, recu
 			}
 		}
 	} else {
-		if err := fs.copyFileInternal(source, target, nil, srcInfo); err != nil {
+		if err := fs.copyFileInternal(source, target, nil, srcInfo, updateModTime); err != nil {
 			return numFiles, filesSize, err
 		}
 		numFiles++

+ 4 - 1
internal/vfs/httpfs.go

@@ -384,7 +384,7 @@ func (fs *HTTPFs) Create(name string, flag, checks int) (File, PipeWriter, func(
 }
 
 // Rename renames (moves) source to target.
-func (fs *HTTPFs) Rename(source, target string) (int, int64, error) {
+func (fs *HTTPFs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
@@ -397,6 +397,9 @@ func (fs *HTTPFs) Rename(source, target string) (int, int64, error) {
 		return -1, -1, err
 	}
 	defer resp.Body.Close()
+	if checks&CheckUpdateModTime != 0 {
+		fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+	}
 	return -1, -1, nil
 }
 

+ 7 - 1
internal/vfs/osfs.go

@@ -176,7 +176,7 @@ func (fs *OsFs) Create(name string, flag, _ int) (File, PipeWriter, func(), erro
 }
 
 // Rename renames (moves) source to target
-func (fs *OsFs) Rename(source, target string) (int, int64, error) {
+func (fs *OsFs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
@@ -199,9 +199,15 @@ func (fs *OsFs) Rename(source, target string) (int, int64, error) {
 			fsLog(fs, logger.LevelError, "cross device copy error: %v", err)
 			return -1, -1, err
 		}
+		if checks&CheckUpdateModTime != 0 {
+			fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+		}
 		err = os.RemoveAll(source)
 		return -1, -1, err
 	}
+	if checks&CheckUpdateModTime != 0 && err == nil {
+		fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+	}
 	return -1, -1, err
 }
 

+ 20 - 10
internal/vfs/s3fs.go

@@ -334,19 +334,21 @@ func (fs *S3Fs) Create(name string, flag, checks int) (File, PipeWriter, func(),
 }
 
 // Rename renames (moves) source to target.
-func (fs *S3Fs) Rename(source, target string) (int, int64, error) {
+func (fs *S3Fs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
-	_, err := fs.Stat(path.Dir(target))
-	if err != nil {
-		return -1, -1, err
+	if checks&CheckParentDir != 0 {
+		_, err := fs.Stat(path.Dir(target))
+		if err != nil {
+			return -1, -1, err
+		}
 	}
 	fi, err := fs.Stat(source)
 	if err != nil {
 		return -1, -1, err
 	}
-	return fs.renameInternal(source, target, fi, 0)
+	return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
 }
 
 // Remove removes the named file or (empty) directory.
@@ -700,21 +702,29 @@ func (fs *S3Fs) copyFileInternal(source, target string, srcInfo os.FileInfo) err
 	ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
 	defer cancelFn()
 
-	_, err := fs.svc.CopyObject(ctx, &s3.CopyObjectInput{
+	copyObject := &s3.CopyObjectInput{
 		Bucket:       aws.String(fs.config.Bucket),
 		CopySource:   aws.String(copySource),
 		Key:          aws.String(target),
 		StorageClass: types.StorageClass(fs.config.StorageClass),
 		ACL:          types.ObjectCannedACL(fs.config.ACL),
 		ContentType:  util.NilIfEmpty(contentType),
-		Metadata:     getMetadata(srcInfo),
-	})
+	}
+
+	metadata := getMetadata(srcInfo)
+	if len(metadata) > 0 {
+		copyObject.Metadata = metadata
+	}
+
+	_, err := fs.svc.CopyObject(ctx, copyObject)
 
 	metric.S3CopyObjectCompleted(err)
 	return err
 }
 
-func (fs *S3Fs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int) (int, int64, error) {
+func (fs *S3Fs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+	updateModTime bool,
+) (int, int64, error) {
 	var numFiles int
 	var filesSize int64
 
@@ -732,7 +742,7 @@ func (fs *S3Fs) renameInternal(source, target string, srcInfo os.FileInfo, recur
 			return numFiles, filesSize, err
 		}
 		if renameMode == 1 {
-			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+			files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
 			numFiles += files
 			filesSize += size
 			if err != nil {

+ 7 - 1
internal/vfs/sftpfs.go

@@ -479,7 +479,7 @@ func (fs *SFTPFs) Create(name string, flag, _ int) (File, PipeWriter, func(), er
 }
 
 // Rename renames (moves) source to target.
-func (fs *SFTPFs) Rename(source, target string) (int, int64, error) {
+func (fs *SFTPFs) Rename(source, target string, checks int) (int, int64, error) {
 	if source == target {
 		return -1, -1, nil
 	}
@@ -489,9 +489,15 @@ func (fs *SFTPFs) Rename(source, target string) (int, int64, error) {
 	}
 	if _, ok := client.HasExtension("[email protected]"); ok {
 		err := client.PosixRename(source, target)
+		if checks&CheckUpdateModTime != 0 && err == nil {
+			fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+		}
 		return -1, -1, err
 	}
 	err = client.Rename(source, target)
+	if checks&CheckUpdateModTime != 0 && err == nil {
+		fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+	}
 	return -1, -1, err
 }
 

+ 8 - 7
internal/vfs/vfs.go

@@ -52,8 +52,9 @@ const (
 
 // Additional checks for files
 const (
-	CheckParentDir = 1
-	CheckResume    = 2
+	CheckParentDir     = 1
+	CheckResume        = 2
+	CheckUpdateModTime = 4
 )
 
 var (
@@ -121,7 +122,7 @@ type Fs interface {
 	Lstat(name string) (os.FileInfo, error)
 	Open(name string, offset int64) (File, PipeReader, func(), error)
 	Create(name string, flag, checks int) (File, PipeWriter, func(), error)
-	Rename(source, target string) (int, int64, error)
+	Rename(source, target string, checks int) (int, int64, error)
 	Remove(name string, isDir bool) error
 	Mkdir(name string) error
 	Symlink(source, target string) error
@@ -1084,7 +1085,7 @@ func IsUploadResumeSupported(fs Fs, size int64) bool {
 }
 
 func getLastModified(metadata map[string]string) int64 {
-	if val, ok := metadata[lastModifiedField]; ok {
+	if val, ok := metadata[lastModifiedField]; ok && val != "" {
 		lastModified, err := strconv.ParseInt(val, 10, 64)
 		if err == nil {
 			return lastModified
@@ -1167,8 +1168,8 @@ func getLocalTempDir() string {
 }
 
 func doRecursiveRename(fs Fs, source, target string,
-	renameFn func(string, string, os.FileInfo, int) (int, int64, error),
-	recursion int,
+	renameFn func(string, string, os.FileInfo, int, bool) (int, int64, error),
+	recursion int, updateModTime bool,
 ) (int, int64, error) {
 	var numFiles int
 	var filesSize int64
@@ -1193,7 +1194,7 @@ func doRecursiveRename(fs Fs, source, target string,
 		for _, info := range entries {
 			sourceEntry := fs.Join(source, info.Name())
 			targetEntry := fs.Join(target, info.Name())
-			files, size, err := renameFn(sourceEntry, targetEntry, info, recursion)
+			files, size, err := renameFn(sourceEntry, targetEntry, info, recursion, updateModTime)
 			if err != nil {
 				if fs.IsNotExist(err) {
 					fsLog(fs, logger.LevelInfo, "skipping rename for %q: %v", sourceEntry, err)

+ 1 - 1
internal/webdavd/handler.go

@@ -254,7 +254,7 @@ func (c *Connection) handleUploadToExistingFile(fs vfs.Fs, resolvedPath, filePat
 	maxWriteSize, _ := c.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported())
 
 	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
-		_, _, err = fs.Rename(resolvedPath, filePath)
+		_, _, err = fs.Rename(resolvedPath, filePath, 0)
 		if err != nil {
 			c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
 				resolvedPath, filePath, err)

+ 1 - 1
internal/webdavd/internal_test.go

@@ -312,7 +312,7 @@ func (fs *MockOsFs) Remove(name string, _ bool) error {
 }
 
 // Rename renames (moves) source to target
-func (fs *MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs *MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
 	err := os.Rename(source, target)
 	return -1, -1, err
 }

+ 9 - 1
openapi/openapi.yaml

@@ -6973,6 +6973,14 @@ components:
           type: string
         value:
           type: string
+    RenameConfig:
+      allOf:
+        - $ref: '#/components/schemas/KeyValue'
+        - type: object
+          properties:
+            update_modtime:
+              type: boolean
+              description: 'Update modification time. This setting is not recursive and only applies to storage providers that support changing modification times'
     HTTPPart:
       type: object
       properties:
@@ -7105,7 +7113,7 @@ components:
         renames:
           type: array
           items:
-            $ref: '#/components/schemas/KeyValue'
+            $ref: '#/components/schemas/RenameConfig'
         mkdirs:
           type: array
           items:

+ 1 - 0
static/locales/en/translation.json

@@ -1007,6 +1007,7 @@
         "archive_path": "Archive path",
         "archive_path_help": "Full path, as seen by SFTPGo users, to the zip archive to create. Placeholders are supported. If the specified file already exists, it is overwritten",
         "placeholders_modal_title": "Supported placeholders",
+        "update_mod_times": "Update timestamp",
         "types": {
             "http": "HTTP",
             "email": "Email",

+ 1 - 0
static/locales/it/translation.json

@@ -1007,6 +1007,7 @@
         "archive_path": "Percorso dell'archivio",
         "archive_path_help": "Percorso completo, come visto dagli utenti SFTPGo, dell'archivio zip da creare. I segnaposto sono supportati. Se il file specificato esiste già, verrà sovrascritto",
         "placeholders_modal_title": "Segnaposto supportati",
+        "update_mod_times": "Aggiorna timestamp",
         "types": {
             "http": "HTTP",
             "email": "Email",

+ 16 - 4
templates/webadmin/eventaction.html

@@ -653,12 +653,18 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 <div data-repeater-item>
                                     <div data-repeater-item>
                                         <div class="form-group row">
-                                            <div class="col-md-5 mt-3 mt-md-8">
+                                            <div class="col-md-4 mt-3 mt-md-8">
                                                 <input data-i18n="[placeholder]actions.source_path" type="text" class="form-control" name="fs_rename_source" value="{{$val.Key}}" spellcheck="false" />
                                             </div>
-                                            <div class="col-md-6 mt-3 mt-md-8">
+                                            <div class="col-md-4 mt-3 mt-md-8">
                                                 <input data-i18n="[placeholder]actions.target_path" type="text" class="form-control" name="fs_rename_target" value="{{$val.Value}}" spellcheck="false" />
                                             </div>
+                                            <div class="col-md-3 mt-3 mt-md-8">
+                                                <select name="fs_rename_options" data-i18n="[data-placeholder]general.options" class="form-select select-repetear" data-allow-clear="true" data-close-on-select="false" data-hide-search="true" multiple>
+                                                    <option value=""></option>
+                                                    <option value="1" data-i18n="actions.update_mod_times" {{if $val.UpdateModTime}}selected{{end}}>Change times</option>
+                                                </select>
+                                            </div>
                                             <div class="col-md-1 mt-3 mt-md-8">
                                                 <a href="#" data-repeater-delete
                                                     class="btn btn-light-danger ps-5 pe-4">
@@ -677,12 +683,18 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 {{- else}}
                                 <div data-repeater-item>
                                     <div class="form-group row">
-                                        <div class="col-md-5 mt-3 mt-md-8">
+                                        <div class="col-md-4 mt-3 mt-md-8">
                                             <input data-i18n="[placeholder]actions.source_path" type="text" class="form-control" name="fs_rename_source" value="" spellcheck="false" />
                                         </div>
-                                        <div class="col-md-6 mt-3 mt-md-8">
+                                        <div class="col-md-4 mt-3 mt-md-8">
                                             <input data-i18n="[placeholder]actions.target_path" type="text" class="form-control" name="fs_rename_target" value="" spellcheck="false" />
                                         </div>
+                                        <div class="col-md-3 mt-3 mt-md-8">
+                                            <select name="fs_rename_options" data-i18n="[data-placeholder]general.options" class="form-select select-repetear" data-allow-clear="true" data-close-on-select="false" data-hide-search="true" multiple>
+                                                <option value=""></option>
+                                                <option value="1" data-i18n="actions.update_mod_times">Update timestamps</option>
+                                            </select>
+                                        </div>
                                         <div class="col-md-1 mt-3 mt-md-8">
                                             <a href="#" data-repeater-delete
                                                 class="btn btn-light-danger ps-5 pe-4">